Bug 1493536 - Convert search-one-offs binding to custom element;r=dao
authorBrian Grinstead <bgrinstead@mozilla.com>
Thu, 25 Oct 2018 09:36:15 +0000
changeset 443018 ec90e4f6f2a2ff225bc8e49975deb5d0cc31bce7
parent 443017 f0ed99b29aafb5fd7f613a000634b53927af4259
child 443019 3c935139d3d84dde6579d5302fdd5d06a96d0f74
push id71751
push userbgrinstead@mozilla.com
push dateThu, 25 Oct 2018 17:25:19 +0000
treeherderautoland@ec90e4f6f2a2 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersdao
bugs1493536
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 1493536 - Convert search-one-offs binding to custom element;r=dao Differential Revision: https://phabricator.services.mozilla.com/D9710
browser/base/content/browser.css
browser/base/content/global-scripts.inc
browser/base/content/test/performance/browser_urlbar_keyed_search.js
browser/base/content/test/performance/browser_urlbar_search.js
browser/base/content/test/urlbar/browser_urlbarOneOffs_settings.js
browser/base/content/urlbarBindings.xml
browser/components/enterprisepolicies/tests/browser/browser_policy_search_engine.js
browser/components/search/content/search-one-offs.js
browser/components/search/content/search.xml
browser/components/search/jar.mn
browser/components/search/test/browser_oneOffContextMenu.js
browser/components/search/test/browser_oneOffContextMenu_setDefault.js
browser/components/search/test/browser_oneOffHeader.js
browser/components/search/test/browser_searchbar_keyboard_navigation.js
browser/components/search/test/browser_searchbar_smallpanel_keyboard_navigation.js
browser/components/search/test/browser_tooManyEnginesOffered.js
browser/components/search/test/head.js
browser/themes/shared/searchbar.inc.css
--- a/browser/base/content/browser.css
+++ b/browser/base/content/browser.css
@@ -46,22 +46,18 @@
 #main-window[customize-entered] {
   min-width: -moz-fit-content;
 }
 
 .searchbar-textbox {
   -moz-binding: url("chrome://browser/content/search/search.xml#searchbar-textbox");
 }
 
-.search-one-offs {
-  -moz-binding: url("chrome://browser/content/search/search.xml#search-one-offs");
-}
-
-.search-setting-button[compact=true],
-.search-setting-button-compact:not([compact=true]) {
+.search-one-offs[compact=true] .search-setting-button,
+.search-one-offs:not([compact=true]) .search-setting-button-compact {
   display: none;
 }
 
 /* Prevent shrinking the page content to 0 height and width */
 .browserStack > browser {
   min-height: 25px;
   min-width: 25px;
 }
--- a/browser/base/content/global-scripts.inc
+++ b/browser/base/content/global-scripts.inc
@@ -12,16 +12,17 @@
 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/performance/browser_urlbar_keyed_search.js
+++ b/browser/base/content/test/performance/browser_urlbar_keyed_search.js
@@ -16,18 +16,18 @@ requestLongerTimeout(5);
 
 /* These reflows happen only the first time the awesomebar panel opens. */
 const EXPECTED_REFLOWS_FIRST_OPEN = [];
 if (AppConstants.DEBUG ||
     AppConstants.platform == "win" ||
     AppConstants.platform == "macosx") {
   EXPECTED_REFLOWS_FIRST_OPEN.push({
     stack: [
-      "_rebuild@chrome://browser/content/search/search.xml",
-      "set_popup@chrome://browser/content/search/search.xml",
+      "_rebuild@chrome://browser/content/search/search-one-offs.js",
+      "set popup@chrome://browser/content/search/search-one-offs.js",
       "set_oneOffSearchesEnabled@chrome://browser/content/urlbarBindings.xml",
       "_enableOrDisableOneOffSearches@chrome://browser/content/urlbarBindings.xml",
       "@chrome://browser/content/urlbarBindings.xml",
       "_openAutocompletePopup@chrome://browser/content/urlbarBindings.xml",
       "openAutocompletePopup@chrome://browser/content/urlbarBindings.xml",
       "openPopup@chrome://global/content/bindings/autocomplete.xml",
       "set_popupOpen@chrome://global/content/bindings/autocomplete.xml",
     ],
--- a/browser/base/content/test/performance/browser_urlbar_search.js
+++ b/browser/base/content/test/performance/browser_urlbar_search.js
@@ -17,18 +17,18 @@ requestLongerTimeout(5);
 /* These reflows happen only the first time the awesomebar panel opens. */
 const EXPECTED_REFLOWS_FIRST_OPEN = [];
 if (AppConstants.DEBUG ||
     AppConstants.platform == "linux" ||
     AppConstants.platform == "macosx" ||
     AppConstants.isPlatformAndVersionAtLeast("win", "10")) {
   EXPECTED_REFLOWS_FIRST_OPEN.push({
     stack: [
-      "_rebuild@chrome://browser/content/search/search.xml",
-      "set_popup@chrome://browser/content/search/search.xml",
+      "_rebuild@chrome://browser/content/search/search-one-offs.js",
+      "set popup@chrome://browser/content/search/search-one-offs.js",
       "set_oneOffSearchesEnabled@chrome://browser/content/urlbarBindings.xml",
       "_enableOrDisableOneOffSearches@chrome://browser/content/urlbarBindings.xml",
       "@chrome://browser/content/urlbarBindings.xml",
       "_openAutocompletePopup@chrome://browser/content/urlbarBindings.xml",
       "openAutocompletePopup@chrome://browser/content/urlbarBindings.xml",
       "openPopup@chrome://global/content/bindings/autocomplete.xml",
       "set_popupOpen@chrome://global/content/bindings/autocomplete.xml",
     ],
--- a/browser/base/content/test/urlbar/browser_urlbarOneOffs_settings.js
+++ b/browser/base/content/test/urlbar/browser_urlbarOneOffs_settings.js
@@ -54,18 +54,19 @@ async function selectSettings(activateFn
       "Should have opened the search preferences pane");
   });
 }
 
 add_task(async function test_open_settings_with_enter() {
   await selectSettings(() => {
     EventUtils.synthesizeKey("KEY_ArrowUp");
 
-    Assert.equal(gURLBar.popup.oneOffSearchButtons.selectedButton.getAttribute("anonid"),
-      "search-settings-compact", "Should have selected the settings button");
+    Assert.ok(gURLBar.popup.oneOffSearchButtons.selectedButton
+      .classList.contains("search-setting-button-compact"),
+      "Should have selected the settings button");
 
     EventUtils.synthesizeKey("KEY_Enter");
   });
 });
 
 add_task(async function test_open_settings_with_click() {
   await selectSettings(() => {
     gURLBar.popup.oneOffSearchButtons.settingsButton.click();
--- a/browser/base/content/urlbarBindings.xml
+++ b/browser/base/content/urlbarBindings.xml
@@ -1871,22 +1871,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:vbox anonid="one-off-search-buttons"
-                  class="search-one-offs"
-                  compact="true"
-                  includecurrentengine="true"
-                  disabletab="true"
-                  flex="1"/>
+        <xul:search-one-offs 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.
       -->
--- a/browser/components/enterprisepolicies/tests/browser/browser_policy_search_engine.js
+++ b/browser/components/enterprisepolicies/tests/browser/browser_policy_search_engine.js
@@ -28,19 +28,17 @@ async function test_opensearch(shouldWor
   let promiseSearchPopupShown = BrowserTestUtils.waitForEvent(searchPopup, "popupshown");
   let searchBarButton = searchBar.querySelector(".searchbar-search-button");
 
   searchBarButton.click();
   await promiseSearchPopupShown;
   let oneOffsContainer = document.getAnonymousElementByAttribute(searchPopup,
                                                                  "anonid",
                                                                  "search-one-off-buttons");
-  let engineListElement = document.getAnonymousElementByAttribute(oneOffsContainer,
-                                                                  "anonid",
-                                                                  "add-engines");
+  let engineListElement = oneOffsContainer.querySelector(".search-add-engines");
   if (shouldWork) {
     ok(engineListElement.firstElementChild,
        "There should be search engines available to add");
     ok(searchBar.getAttribute("addengines"),
        "Search bar should have addengines attribute");
   } else {
     is(engineListElement.firstElementChild, null,
        "There should be no search engines available to add");
copy from browser/components/search/content/search.xml
copy to browser/components/search/content/search-one-offs.js
--- a/browser/components/search/content/search.xml
+++ b/browser/components/search/content/search-one-offs.js
@@ -1,1841 +1,1193 @@
-<?xml version="1.0"?>
-<!-- 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/. -->
-
-<!-- XULCommandEvent is a specialised global. -->
-<!-- global XULCommandEvent -->
-
-<!DOCTYPE bindings [
-<!ENTITY % browserDTD SYSTEM "chrome://browser/locale/browser.dtd">
-%browserDTD;
-]>
+/* 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/. */
 
-<bindings id="SearchBindings"
-          xmlns="http://www.mozilla.org/xbl"
-          xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
-          xmlns:xbl="http://www.mozilla.org/xbl">
+"use strict";
 
-  <binding id="searchbar-textbox"
-      extends="chrome://global/content/bindings/autocomplete.xml#autocomplete">
-    <implementation>
-      <constructor><![CDATA[
-        if (this.closest("searchbar").parentNode.parentNode.localName ==
-            "toolbarpaletteitem")
-          return;
+/* eslint-env mozilla/browser-window */
+
+{
 
-        if (Services.prefs.getBoolPref("browser.urlbar.clickSelectsAll"))
-          this.setAttribute("clickSelectsAll", true);
-
-        var textBox = document.getAnonymousElementByAttribute(this,
-                                              "anonid", "moz-input-box");
-
-        // Force the Custom Element to upgrade until Bug 1470242 handles this:
-        customElements.upgrade(textBox);
-        var cxmenu = textBox.menupopup;
-        cxmenu.addEventListener("popupshowing",
-                                () => { this.initContextMenu(cxmenu); },
-                                {capture: true, once: true});
+class MozSearchOneOffs extends MozXULElement {
+  constructor() {
+    super();
 
-        this.setAttribute("aria-owns", this.popup.id);
-        this.closest("searchbar")._textboxInitialized = true;
-      ]]></constructor>
+    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();
+    });
 
-      <destructor><![CDATA[
-        // If the context menu has never been opened, there won't be anything
-        // to remove here.
-        // Also, XBL and the customize toolbar code sometimes interact poorly.
-        try {
-          this.controllers.removeController(this.searchbarController);
-        } catch (ex) { }
-      ]]></destructor>
+    this.addEventListener("mousemove", event => {
+      let target = event.originalTarget;
 
-      // Add items to context menu and attach controller to handle them the
-      // first time the context menu is opened.
-      <method name="initContextMenu">
-        <parameter name="aMenu"/>
-        <body><![CDATA[
-          let stringBundle = this.closest("searchbar")._stringBundle;
-
-          let pasteAndSearch, suggestMenuItem;
-          let element, label, akey;
-
-          element = document.createXULElement("menuseparator");
-          aMenu.appendChild(element);
+      // 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;
+      }
 
-          let insertLocation = aMenu.firstElementChild;
-          while (insertLocation.nextElementSibling &&
-                 insertLocation.getAttribute("cmd") != "cmd_paste")
-            insertLocation = insertLocation.nextElementSibling;
-          if (insertLocation) {
-            element = document.createXULElement("menuitem");
-            label = stringBundle.getString("cmd_pasteAndSearch");
-            element.setAttribute("label", label);
-            element.setAttribute("anonid", "paste-and-search");
-            element.setAttribute("oncommand", "BrowserSearch.pasteAndSearch(event)");
-            aMenu.insertBefore(element, insertLocation.nextElementSibling);
-            pasteAndSearch = element;
-          }
+      if (target.localName != "button") {
+        return;
+      }
 
-          element = document.createXULElement("menuitem");
-          label = stringBundle.getString("cmd_clearHistory");
-          akey = stringBundle.getString("cmd_clearHistory_accesskey");
-          element.setAttribute("label", label);
-          element.setAttribute("accesskey", akey);
-          element.setAttribute("cmd", "cmd_clearhistory");
-          aMenu.appendChild(element);
+      // Ignore mouse events when the context menu is open.
+      if (this._ignoreMouseEvents) {
+        return;
+      }
 
-          element = document.createXULElement("menuitem");
-          label = stringBundle.getString("cmd_showSuggestions");
-          akey = stringBundle.getString("cmd_showSuggestions_accesskey");
-          element.setAttribute("anonid", "toggle-suggest-item");
-          element.setAttribute("label", label);
-          element.setAttribute("accesskey", akey);
-          element.setAttribute("cmd", "cmd_togglesuggest");
-          element.setAttribute("type", "checkbox");
-          element.setAttribute("autocheck", "false");
-          suggestMenuItem = element;
-          aMenu.appendChild(element);
-
-          if (AppConstants.platform == "macosx") {
-            this.addEventListener("keypress", aEvent => {
-              if (aEvent.keyCode == KeyEvent.DOM_VK_F4)
-                this.openSearch();
-            }, true);
-          }
-
-          this.controllers.appendController(this.searchbarController);
-
-          let onpopupshowing = function() {
-            BrowserSearch.searchBar._textbox.closePopup();
-            if (suggestMenuItem) {
-              let enabled =
-                Services.prefs.getBoolPref("browser.search.suggest.enabled");
-              suggestMenuItem.setAttribute("checked", enabled);
-            }
+      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);
+      }
+    });
 
-            if (!pasteAndSearch)
-              return;
-            let controller = document.commandDispatcher.getControllerForCommand("cmd_paste");
-            let enabled = controller.isCommandEnabled("cmd_paste");
-            if (enabled)
-              pasteAndSearch.removeAttribute("disabled");
-            else
-              pasteAndSearch.setAttribute("disabled", "true");
-          };
-          aMenu.addEventListener("popupshowing", onpopupshowing);
-          onpopupshowing();
-        ]]></body>
-      </method>
+    this.addEventListener("mouseout", event => {
+      let target = event.originalTarget;
 
-      <!--
-        This overrides the searchParam property in autocomplete.xml.  We're
-        hijacking this property as a vehicle for delivering the privacy
-        information about the window into the guts of nsSearchSuggestions.
-
-        Note that the setter is the same as the parent.  We were not sure whether
-        we can override just the getter.  If that proves to be the case, the setter
-        can be removed.
-      -->
-      <property name="searchParam"
-                onget="return this.getAttribute('autocompletesearchparam') +
-                       (PrivateBrowsingUtils.isWindowPrivate(window) ? '|private' : '');"
-                onset="this.setAttribute('autocompletesearchparam', val); return val;"/>
+      // 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;
+      }
 
-      <!-- This is implemented so that when textbox.value is set directly (e.g.,
-           by tests), the one-off query is updated. -->
-      <method name="onBeforeValueSet">
-        <parameter name="aValue"/>
-        <body><![CDATA[
-          this.popup.oneOffButtons.query = aValue;
-          return aValue;
-        ]]></body>
-      </method>
+      if (target.localName != "button") {
+        return;
+      }
 
-      <!--
-        This method overrides the autocomplete binding's openPopup (essentially
-        duplicating the logic from the autocomplete popup binding's
-        openAutocompletePopup method), modifying it so that the popup is aligned with
-        the inner textbox, but sized to not extend beyond the search bar border.
-      -->
-      <method name="openPopup">
-        <body><![CDATA[
-          // Entering customization mode after the search bar had focus causes
-          // the popup to appear again, due to focus returning after the
-          // hamburger panel closes. Don't open in that spurious event.
-          if (document.documentElement.getAttribute("customizing") == "true") {
-            return;
-          }
+      // Don't update the mouseover state if the context menu is open.
+      if (this._ignoreMouseEvents) {
+        return;
+      }
 
-          var popup = this.popup;
-          if (!popup.mPopupOpen) {
-            // Initially the panel used for the searchbar (PopupSearchAutoComplete
-            // in browser.xul) is hidden to avoid impacting startup / new
-            // window performance. The base binding's openPopup would normally
-            // call the overriden openAutocompletePopup in
-            // browser-search-autocomplete-result-popup binding to unhide the popup,
-            // but since we're overriding openPopup we need to unhide the panel
-            // ourselves.
-            popup.hidden = false;
+      this._updateStateForButton(null);
+    });
 
-            // Don't roll up on mouse click in the anchor for the search UI.
-            if (popup.id == "PopupSearchAutoComplete") {
-              popup.setAttribute("norolluponanchor", "true");
-            }
-
-            popup.mInput = this;
-            // clear any previous selection, see bugs 400671 and 488357
-            popup.selectedIndex = -1;
-
-            document.popupNode = null;
-
-            const isRTL = getComputedStyle(this, "").direction == "rtl";
+    this.addEventListener("click", event => {
+      if (event.button == 2) {
+        return; // ignore right clicks.
+      }
 
-            var outerRect = this.getBoundingClientRect();
-            var innerRect = this.inputField.getBoundingClientRect();
-            let width = isRTL ?
-                        innerRect.right - outerRect.left :
-                        outerRect.right - innerRect.left;
-            popup.setAttribute("width", width > 100 ? width : 100);
+      let button = event.originalTarget;
+      let engine = button.engine;
 
-            // invalidate() depends on the width attribute
-            popup._invalidate();
-
-            var yOffset = outerRect.bottom - innerRect.bottom;
-            popup.openPopup(this.inputField, "after_start", 0, yOffset, false, false);
-          }
-        ]]></body>
-      </method>
+      if (!engine) {
+        return;
+      }
 
-      <method name="openSearch">
-        <body>
-          <![CDATA[
-            if (!this.popupOpen) {
-              this.closest("searchbar").openSuggestionsPanel();
-              return false;
-            }
-            return true;
-          ]]>
-        </body>
-      </method>
+      // Select the clicked button so that consumers can easily tell which
+      // button was acted on.
+      this.selectedButton = button;
+      this.handleSearchCommand(event, engine);
+    });
 
-      <method name="handleEnter">
-        <parameter name="event"/>
-        <body><![CDATA[
-          // Toggle the open state of the add-engine menu button if it's
-          // selected.  We're using handleEnter for this instead of listening
-          // for the command event because a command event isn't fired.
-          if (this.selectedButton &&
-              this.selectedButton.getAttribute("anonid") ==
-                "addengine-menu-button") {
-            this.selectedButton.open = !this.selectedButton.open;
-            return true;
-          }
-          // Otherwise, "call super": do what the autocomplete binding's
-          // handleEnter implementation does.
-          return this.mController.handleEnter(false, event || null);
-        ]]></body>
-      </method>
-
-      <!-- override |onTextEntered| in autocomplete.xml -->
-      <method name="onTextEntered">
-        <parameter name="aEvent"/>
-        <body><![CDATA[
-          let engine;
-          let oneOff = this.selectedButton;
-          if (oneOff) {
-            if (!oneOff.engine) {
-              oneOff.doCommand();
+    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;
             }
-            engine = oneOff.engine;
-          }
-          if (this._selectionDetails) {
-            BrowserSearch.searchBar.telemetrySearchDetails = this._selectionDetails;
-            this._selectionDetails = null;
-          }
-          this.closest("searchbar").handleSearchCommand(aEvent, engine);
-        ]]></body>
-      </method>
-
-      <property name="selectedButton">
-        <getter><![CDATA[
-          return this.popup.oneOffButtons.selectedButton;
-        ]]></getter>
-        <setter><![CDATA[
-          return this.popup.oneOffButtons.selectedButton = val;
-        ]]></setter>
-      </property>
-
-      <method name="handleKeyboardNavigation">
-        <parameter name="aEvent"/>
-        <body><![CDATA[
-          let popup = this.popup;
-          if (!popup.popupOpen)
-            return;
-
-          // accel + up/down changes the default engine and shouldn't affect
-          // the selection on the one-off buttons.
-          if (aEvent.getModifierState("Accel"))
-            return;
-
-          let suggestionsHidden =
-            popup.richlistbox.getAttribute("collapsed") == "true";
-          let numItems = suggestionsHidden ? 0 : this.popup.matchCount;
-          this.popup.oneOffButtons.handleKeyPress(aEvent, numItems, true);
-        ]]></body>
-      </method>
-
-      <!-- nsIController -->
-      <field name="searchbarController" readonly="true"><![CDATA[({
-        _self: this,
-        supportsCommand(aCommand) {
-          return aCommand == "cmd_clearhistory" ||
-                 aCommand == "cmd_togglesuggest";
-        },
-
-        isCommandEnabled(aCommand) {
-          return true;
-        },
-
-        doCommand(aCommand) {
-          switch (aCommand) {
-            case "cmd_clearhistory":
-              var param = this._self.getAttribute("autocompletesearchparam");
-
-              BrowserSearch.searchBar.FormHistory.update({ op: "remove", fieldname: param }, null);
-              this._self.value = "";
-              break;
-            case "cmd_togglesuggest":
-              let enabled =
-                Services.prefs.getBoolPref("browser.search.suggest.enabled");
-              Services.prefs.setBoolPref("browser.search.suggest.enabled",
-                                         !enabled);
-              break;
-            default:
-              // do nothing with unrecognized command
-          }
-        },
-      })]]></field>
-    </implementation>
-
-    <handlers>
-      <handler event="input"><![CDATA[
-        this.popup.removeAttribute("showonlysettings");
-      ]]></handler>
-
-      <handler event="keypress" phase="capturing"
-               action="return this.handleKeyboardNavigation(event);"/>
-
-      <handler event="keypress" keycode="VK_UP" modifiers="accel"
-               phase="capturing"
-               action='this.closest("searchbar").selectEngine(event, false);'/>
-
-      <handler event="keypress" keycode="VK_DOWN" modifiers="accel"
-               phase="capturing"
-               action='this.closest("searchbar").selectEngine(event, true);'/>
-
-      <handler event="keypress" keycode="VK_DOWN" modifiers="alt"
-               phase="capturing"
-               action="return this.openSearch();"/>
-
-      <handler event="keypress" keycode="VK_UP" modifiers="alt"
-               phase="capturing"
-               action="return this.openSearch();"/>
-
-      <handler event="dragover">
-      <![CDATA[
-        var types = event.dataTransfer.types;
-        if (types.includes("text/plain") || types.includes("text/x-moz-text-internal"))
-          event.preventDefault();
-      ]]>
-      </handler>
-
-      <handler event="drop">
-      <![CDATA[
-        var dataTransfer = event.dataTransfer;
-        var data = dataTransfer.getData("text/plain");
-        if (!data)
-          data = dataTransfer.getData("text/x-moz-text-internal");
-        if (data) {
-          event.preventDefault();
-          this.value = data;
-          this.closest("searchbar").openSuggestionsPanel();
-        }
-      ]]>
-      </handler>
-
-    </handlers>
-  </binding>
-
-  <binding id="browser-search-autocomplete-result-popup" extends="chrome://global/content/bindings/autocomplete.xml#autocomplete-rich-result-popup">
-    <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:vbox 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
-          aInput.popup.hidden = false;
-
-          // this method is defined on the base binding
-          this._openAutocompletePopup(aInput, aElement);
-        ]]></body>
-      </method>
+            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.currentEngine;
 
-      <method name="onPopupClick">
-        <parameter name="aEvent"/>
-        <body><![CDATA[
-          // Ignore all right-clicks
-          if (aEvent.button == 2)
-            return;
-
-          var searchBar = BrowserSearch.searchBar;
-          var popupForSearchBar = searchBar && searchBar.textbox == this.mInput;
-          if (popupForSearchBar) {
-            searchBar.telemetrySearchDetails = {
-              index: this.selectedIndex,
-              kind: "mouse",
-            };
-          }
-
-          // Check for unmodified left-click, and use default behavior
-          if (aEvent.button == 0 && !aEvent.shiftKey && !aEvent.ctrlKey &&
-              !aEvent.altKey && !aEvent.metaKey) {
-            this.input.controller.handleEnter(true, aEvent);
-            return;
-          }
-
-          // Check for middle-click or modified clicks on the search bar
-          if (popupForSearchBar) {
-            BrowserUsageTelemetry.recordSearchbarSelectedResultMethod(
-              aEvent,
-              this.selectedIndex
-            );
-
-            // Handle search bar popup clicks
-            var search = this.input.controller.getValueAt(this.selectedIndex);
-
-            // open the search results according to the clicking subtlety
-            var where = whereToOpenLink(aEvent, false, true);
-            let params = {};
-
-            // But open ctrl/cmd clicks on autocomplete items in a new background tab.
-            let modifier = AppConstants.platform == "macosx" ?
-                           aEvent.metaKey :
-                           aEvent.ctrlKey;
-            if (where == "tab" && (aEvent instanceof MouseEvent) &&
-                (aEvent.button == 1 || modifier))
-              params.inBackground = true;
-
-            // leave the popup open for background tab loads
-            if (!(where == "tab" && params.inBackground)) {
-              // close the autocomplete popup and revert the entered search term
-              this.closePopup();
-              this.input.controller.handleEscape();
-            }
-
-            searchBar.doSearch(search, where, null, params);
-            if (where == "tab" && params.inBackground)
-              searchBar.focus();
-            else
-              searchBar.value = search;
+        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;
           }
-        ]]></body>
-      </method>
-
-      <!-- Popup rollup is triggered by native events before the mousedown event
-           reaches the DOM. The will be set to true by the popuphiding event and
-           false after the mousedown event has been triggered to detect what
-           caused rollup. -->
-      <field name="_isHiding">false</field>
-      <field name="_bundle">null</field>
-      <property name="bundle" readonly="true">
-        <getter>
-          <![CDATA[
-            if (!this._bundle) {
-              const kBundleURI = "chrome://browser/locale/search.properties";
-              this._bundle = Services.strings.createBundle(kBundleURI);
-            }
-            return this._bundle;
-          ]]>
-        </getter>
-      </property>
-
-      <field name="oneOffButtons" readonly="true">
-        document.getAnonymousElementByAttribute(this, "anonid",
-                                                "search-one-off-buttons");
-      </field>
-
-      <method name="updateHeader">
-        <body><![CDATA[
-          let currentEngine = Services.search.currentEngine;
-          let uri = currentEngine.iconURI;
-          if (uri) {
-            this.setAttribute("src", uri.spec);
-          } else {
-            // If the default has just been changed to a provider without icon,
-            // avoid showing the icon of the previous default provider.
-            this.removeAttribute("src");
-          }
-
-          let headerText = this.bundle.formatStringFromName("searchHeader",
-                                                            [currentEngine.name], 1);
-          document.getAnonymousElementByAttribute(this, "anonid", "searchbar-engine-name")
-                  .setAttribute("value", headerText);
-          document.getAnonymousElementByAttribute(this, "anonid", "searchbar-engine")
-                  .engine = currentEngine;
-        ]]></body>
-      </method>
-
-      <!-- This is called when a one-off is clicked and when "search in new tab"
-           is selected from a one-off context menu. -->
-      <method name="handleOneOffSearch">
-        <parameter name="event"/>
-        <parameter name="engine"/>
-        <parameter name="where"/>
-        <parameter name="params"/>
-        <body><![CDATA[
-          let searchbar = document.getElementById("searchbar");
-          searchbar.handleSearchCommandWhere(event, engine, where, params);
-        ]]></body>
-      </method>
-    </implementation>
-
-    <handlers>
-      <handler event="popupshowing"><![CDATA[
-        // Force the panel to have the width of the searchbar rather than
-        // the width of the textfield.
-        let DOMUtils = window.windowUtils;
-        let textboxRect = DOMUtils.getBoundsWithoutFlushing(this.mInput);
-        let inputRect = DOMUtils.getBoundsWithoutFlushing(this.mInput.inputField);
-
-        // Ensure the panel is wide enough to fit at least 3 engines.
-        let minWidth = Math.max(textboxRect.width,
-                                this.oneOffButtons.buttonWidth * 3);
-        this.style.minWidth = Math.round(minWidth) + "px";
-        // Alignment of the panel with the searchbar is obtained with negative
-        // margins.
-        this.style.marginLeft = (textboxRect.left - inputRect.left) + "px";
-        // This second margin is needed when the direction is reversed,
-        // eg. when using command+shift+X.
-        this.style.marginRight = (inputRect.right - textboxRect.right) + "px";
-
-        // First handle deciding if we are showing the reduced version of the
-        // popup containing only the preferences button. We do this if the
-        // glass icon has been clicked if the text field is empty.
-        let searchbar = document.getElementById("searchbar");
-        if (searchbar.hasAttribute("showonlysettings")) {
-          searchbar.removeAttribute("showonlysettings");
-          this.setAttribute("showonlysettings", "true");
-
-          // Setting this with an xbl-inherited attribute gets overridden the
-          // second time the user clicks the glass icon for some reason...
-          this.richlistbox.collapsed = true;
-        } else {
-          this.removeAttribute("showonlysettings");
-          // Uncollapse as long as we have a view which has >= 1 row.
-          // The autocomplete binding itself will take care of uncollapsing later,
-          // if we currently have no rows but end up having some in the future
-          // when the search string changes
-          this.richlistbox.collapsed = (this.matchCount == 0);
+          button.setAttribute("image", uri);
+          button.setAttribute("tooltiptext", currentEngine.name);
+          button.engine = currentEngine;
         }
 
-        // Show the current default engine in the top header of the panel.
-        this.updateHeader();
-      ]]></handler>
+        Services.search.currentEngine = this._contextEngine;
+      }
+    });
 
-      <handler event="popuphiding"><![CDATA[
-        this._isHiding = true;
-        Services.tm.dispatchToMainThread(() => {
-          this._isHiding = false;
-        });
-      ]]></handler>
+    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.currentEngine);
+
+      this.contextMenuPopup.openPopupAtScreen(event.screenX, event.screenY, true);
+      event.preventDefault();
+
+      this._contextEngine = target.engine;
+    });
+  }
 
-      <!-- This handles clicks on the topmost "Foo Search" header in the
-           popup (hbox[anonid="searchbar-engine"]). -->
-      <handler event="click"><![CDATA[
-        if (event.button == 2) {
-          // Ignore right clicks.
-          return;
-        }
-        let button = event.originalTarget;
-        let engine = button.parentNode.engine;
-        if (!engine) {
-          return;
-        }
-        this.oneOffButtons.handleSearchCommand(event, engine);
-      ]]></handler>
-    </handlers>
-
-  </binding>
+  connectedCallback() {
+    this.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;"></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"])
+    );
 
-  <binding id="search-one-offs">
-    <content context="_child">
-      <xul:deck anonid="search-panel-one-offs-header"
-                selectedIndex="0"
-                class="search-panel-header search-panel-current-input">
-        <xul:label anonid="searchbar-oneoffheader-search"
-                   value="&searchWithHeader.label;"/>
-        <xul:hbox anonid="search-panel-searchforwith"
-                  class="search-panel-current-input">
-          <xul:label anonid="searchbar-oneoffheader-before"
-                     value="&searchFor.label;"/>
-          <xul:label anonid="searchbar-oneoffheader-searchtext"
-                     class="search-panel-input-value"
-                     flex="1"
-                     crop="end"/>
-          <xul:label anonid="searchbar-oneoffheader-after"
-                     flex="10000"
-                     value="&searchWith.label;"/>
-        </xul:hbox>
-        <xul:hbox anonid="search-panel-searchonengine"
-                  class="search-panel-current-input">
-          <xul:label anonid="searchbar-oneoffheader-beforeengine"
-                     value="&search.label;"/>
-          <xul:label anonid="searchbar-oneoffheader-engine"
-                     class="search-panel-input-value"
-                     flex="1"
-                     crop="end"/>
-          <xul:label anonid="searchbar-oneoffheader-afterengine"
-                     flex="10000"
-                     value="&searchAfter.label;"/>
-        </xul:hbox>
-      </xul:deck>
-      <xul:description anonid="search-panel-one-offs"
-                       role="group"
-                       class="search-panel-one-offs"
-                       xbl:inherits="compact">
-        <xul:button anonid="search-settings-compact"
-                    oncommand="showSettings();"
-                    class="searchbar-engine-one-off-item search-setting-button-compact"
-                    tooltiptext="&changeSearchSettings.tooltip;"
-                    xbl:inherits="compact"/>
-      </xul:description>
-      <xul:vbox anonid="add-engines" class="search-add-engines"/>
-      <xul:button anonid="search-settings"
-                  oncommand="showSettings();"
-                  class="search-setting-button search-panel-header"
-                  label="&changeSearchSettings.button;"
-                  xbl:inherits="compact"/>
-      <xul:menupopup anonid="search-one-offs-context-menu">
-        <xul:menuitem anonid="search-one-offs-context-open-in-new-tab"
-                      label="&searchInNewTab.label;"
-                      accesskey="&searchInNewTab.accesskey;"/>
-        <xul:menuitem anonid="search-one-offs-context-set-default"
-                      label="&searchSetAsDefault.label;"
-                      accesskey="&searchSetAsDefault.accesskey;"/>
-      </xul:menupopup>
-    </content>
+    this._popup = null;
+
+    this._textbox = null;
+
+    this._textboxWidth = 0;
+
+    /**
+     * Set this to a string that identifies your one-offs consumer.  It'll
+     * be appended to telemetry recorded with maybeRecordTelemetry().
+     */
+    this.telemetryOrigin = "";
+
+    this._query = "";
+
+    this._selectedButton = null;
+
+    this.buttons = this.querySelector(".search-panel-one-offs");
+
+    this.header = this.querySelector(".search-panel-one-offs-header");
+
+    this.addEngines = this.querySelector(".search-add-engines");
+
+    this.settingsButton = this.querySelector(".search-setting-button");
+
+    this.settingsButtonCompact = this.querySelector(".search-setting-button-compact");
 
-    <implementation implements="nsIObserver,nsIWeakReference">
+    this.contextMenuPopup = this.querySelector(".search-one-offs-context-menu");
+
+    this._bundle = null;
+
+    /**
+     * When a context menu is opened on a one-off button, this is set to the
+     * engine of that button for use with the context menu actions.
+     */
+    this._contextEngine = null;
+
+    this._engines = null;
 
-      <!-- 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. -->
-      <property name="buttonWidth" readonly="true" onget="return 49;"/>
+    /**
+     * If a page offers more than this number of engines, the add-engines
+     * menu button is shown, instead of showing the engines directly in the
+     * popup.
+     */
+    this._addEngineMenuThreshold = 5;
 
-      <field name="_popup">null</field>
+    /**
+     * All this stuff is to make the add-engines menu button behave like an
+     * actual menu.  The add-engines menu button is shown when there are
+     * many engines offered by the current site.
+     */
+    this._addEngineMenuTimeoutMs = 200;
 
-      <!-- The popup that contains the one-offs.  This is required, so it should
-           never be null or undefined, except possibly before the one-offs are
-           used. -->
-      <property name="popup">
-        <getter><![CDATA[
-          return this._popup;
-        ]]></getter>
-        <setter><![CDATA[
-          let events = [
-            "popupshowing",
-            "popuphidden",
-          ];
-          if (this._popup) {
-            for (let event of events) {
-              this._popup.removeEventListener(event, this);
-            }
-          }
-          if (val) {
-            for (let event of events) {
-              val.addEventListener(event, this);
-            }
-          }
-          this._popup = val;
+    this._addEngineMenuTimeout = null;
+
+    this._addEngineMenuShouldBeOpen = false;
 
-          // 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;
-        ]]></setter>
-      </property>
-
-      <field name="_textbox">null</field>
-      <field name="_textboxWidth">0</field>
+    // 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();
+    });
 
-      <!-- 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. -->
-      <property name="textbox">
-        <getter><![CDATA[
-          return this._textbox;
-        ]]></getter>
-        <setter><![CDATA[
-          if (this._textbox) {
-            this._textbox.removeEventListener("input", this);
-          }
-          if (val) {
-            val.addEventListener("input", this);
-          }
-          return this._textbox = val;
-        ]]></setter>
-      </property>
+    // Add weak referenced observers to invalidate our cached list of engines.
+    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);
+  }
 
-      <!-- Set this to a string that identifies your one-offs consumer.  It'll
-           be appended to telemetry recorded with maybeRecordTelemetry(). -->
-      <field name="telemetryOrigin">""</field>
-
-      <field name="_query">""</field>
-
-      <!-- The query string currently shown in the one-offs.  If the textbox
-           property is non-null, then this is automatically updated on
-           input. -->
-      <property name="query">
-        <getter><![CDATA[
-          return this._query;
-        ]]></getter>
-        <setter><![CDATA[
-          this._query = val;
-          if (this.popup && this.popup.popupOpen) {
-            this._updateAfterQueryChanged();
-          }
-          return val;
-        ]]></setter>
-      </property>
-
-      <field name="_selectedButton">null</field>
+  /**
+   * 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;
+  }
+  /**
+   * The popup that contains the one-offs.  This is required, so it should
+   * never be null or undefined, except possibly before the one-offs are
+   * used.
+   */
+  set popup(val) {
+    let events = [
+      "popupshowing",
+      "popuphidden",
+    ];
+    if (this._popup) {
+      for (let event of events) {
+        this._popup.removeEventListener(event, this);
+      }
+    }
+    if (val) {
+      for (let event of events) {
+        val.addEventListener(event, this);
+      }
+    }
+    this._popup = val;
 
-      <!-- The selected one-off, a xul:button, including the add-engine button
-           and the search-settings button.  Null if no one-off is selected. -->
-      <property name="selectedButton">
-        <getter><![CDATA[
-          return this._selectedButton;
-        ]]></getter>
-        <setter><![CDATA[
-          if (val && val.classList.contains("dummy")) {
-            // Never select dummy buttons.
-            val = null;
-          }
-          let previousButton = this._selectedButton;
-          if (previousButton) {
-            previousButton.removeAttribute("selected");
-          }
-          if (val) {
-            val.setAttribute("selected", "true");
-          }
-          this._selectedButton = val;
-          this._updateStateForButton(null);
-          if (val && !val.engine) {
-            // If the button doesn't have an engine, then clear the popup's
-            // selection to indicate that pressing Return while the button is
-            // selected will do the button's command, not search.
-            this.popup.selectedIndex = -1;
-          }
-          let event = new CustomEvent("SelectedOneOffButtonChanged", {
-            previousSelectedButton: previousButton,
-          });
-          this.dispatchEvent(event);
-          return val;
-        ]]></setter>
-      </property>
+    // 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 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) {
+      this._updateAfterQueryChanged();
+    }
+    return val;
+  }
 
-      <!-- The index of the selected one-off, including the add-engine button
-           and the search-settings button.  -1 if no one-off is selected. -->
-      <property name="selectedButtonIndex">
-        <getter><![CDATA[
-          let buttons = this.getSelectableButtons(true);
-          for (let i = 0; i < buttons.length; i++) {
-            if (buttons[i] == this._selectedButton) {
-              return i;
-            }
-          }
-          return -1;
-        ]]></getter>
-        <setter><![CDATA[
-          let buttons = this.getSelectableButtons(true);
-          this.selectedButton = buttons[val];
-          return val;
-        ]]></setter>
-      </property>
-
-      <property name="compact" readonly="true">
-        <getter><![CDATA[
-          return this.getAttribute("compact") == "true";
-        ]]></getter>
-      </property>
+  get query() {
+    return this._query;
+  }
+  /**
+   * The selected one-off, a xul:button, including the add-engine button
+   * and the search-settings button.  Null if no one-off is selected.
+   */
+  set selectedButton(val) {
+    if (val && val.classList.contains("dummy")) {
+      // Never select dummy buttons.
+      val = null;
+    }
+    let previousButton = this._selectedButton;
+    if (previousButton) {
+      previousButton.removeAttribute("selected");
+    }
+    if (val) {
+      val.setAttribute("selected", "true");
+    }
+    this._selectedButton = val;
+    this._updateStateForButton(null);
+    if (val && !val.engine) {
+      // If the button doesn't have an engine, then clear the popup's
+      // selection to indicate that pressing Return while the button is
+      // selected will do the button's command, not search.
+      this.popup.selectedIndex = -1;
+    }
+    let event = new CustomEvent("SelectedOneOffButtonChanged", {
+      previousSelectedButton: previousButton,
+    });
+    this.dispatchEvent(event);
+    return val;
+  }
 
-      <field name="buttons" readonly="true">
-        document.getAnonymousElementByAttribute(this, "anonid", "search-panel-one-offs");
-      </field>
-      <field name="header" readonly="true">
-        document.getAnonymousElementByAttribute(this, "anonid", "search-panel-one-offs-header");
-      </field>
-      <field name="addEngines" readonly="true">
-        document.getAnonymousElementByAttribute(this, "anonid", "add-engines");
-      </field>
-      <field name="settingsButton" readonly="true">
-        document.getAnonymousElementByAttribute(this, "anonid", "search-settings");
-      </field>
-      <field name="settingsButtonCompact" readonly="true">
-        document.getAnonymousElementByAttribute(this, "anonid", "search-settings-compact");
-      </field>
+  get selectedButton() {
+    return this._selectedButton;
+  }
+  /**
+   * The index of the selected one-off, including the add-engine button
+   * and the search-settings button.  -1 if no one-off is selected.
+   */
+  set selectedButtonIndex(val) {
+    let buttons = this.getSelectableButtons(true);
+    this.selectedButton = buttons[val];
+    return val;
+  }
 
-      <field name="_bundle">null</field>
+  get selectedButtonIndex() {
+    let buttons = this.getSelectableButtons(true);
+    for (let i = 0; i < buttons.length; i++) {
+      if (buttons[i] == this._selectedButton) {
+        return i;
+      }
+    }
+    return -1;
+  }
 
-      <property name="bundle" readonly="true">
-        <getter><![CDATA[
-          if (!this._bundle) {
-            const kBundleURI = "chrome://browser/locale/search.properties";
-            this._bundle = Services.strings.createBundle(kBundleURI);
-          }
-          return this._bundle;
-        ]]></getter>
-      </property>
-
-      <!-- When a context menu is opened on a one-off button, this is set to the
-           engine of that button for use with the context menu actions. -->
-      <field name="_contextEngine">null</field>
+  get compact() {
+    return this.getAttribute("compact") == "true";
+  }
 
-      <constructor><![CDATA[
-        // Force the <deck> Custom Element to be constructed. This can be removed
-        // once Bug 1470242 makes this happen behind the scenes.
-        customElements.upgrade(this.header);
+  get bundle() {
+    if (!this._bundle) {
+      const kBundleURI = "chrome://browser/locale/search.properties";
+      this._bundle = Services.strings.createBundle(kBundleURI);
+    }
+    return this._bundle;
+  }
 
-        // Prevent popup events from the context menu from reaching the autocomplete
-        // binding (or other listeners).
-        let menu = document.getAnonymousElementByAttribute(this, "anonid", "search-one-offs-context-menu");
-        let listener = aEvent => aEvent.stopPropagation();
-        menu.addEventListener("popupshowing", listener);
-        menu.addEventListener("popuphiding", listener);
-        menu.addEventListener("popupshown", aEvent => {
-          this._ignoreMouseEvents = true;
-          aEvent.stopPropagation();
-        });
-        menu.addEventListener("popuphidden", aEvent => {
-          this._ignoreMouseEvents = false;
-          aEvent.stopPropagation();
-        });
+  get engines() {
+    if (this._engines) {
+      return this._engines;
+    }
+    let currentEngineNameToIgnore;
+    if (!this.getAttribute("includecurrentengine"))
+      currentEngineNameToIgnore = Services.search.currentEngine.name;
 
-        // Add weak referenced observers to invalidate our cached list of engines.
-        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);
+    let pref = Services.prefs.getStringPref("browser.search.hiddenOneOffs");
+    let hiddenList = pref ? pref.split(",") : [];
+
+    this._engines = Services.search.getVisibleEngines().filter(e => {
+      let name = e.name;
+      return (!currentEngineNameToIgnore ||
+              name != currentEngineNameToIgnore) &&
+             !hiddenList.includes(name);
+    });
+
+    return this._engines;
+  }
 
-        // 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);
-      ]]></constructor>
+  /**
+   * 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;
+    }
+  }
 
-      <!-- This handles events outside the one-off buttons, like on the popup
-           and textbox. -->
-      <method name="handleEvent">
-        <parameter name="event"/>
-        <body><![CDATA[
-          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;
-          }
-        ]]></body>
-      </method>
+  observe(aEngine, aTopic, aData) {
+    // Make sure the engine list is refetched next time it's needed.
+    this._engines = null;
+  }
+
+  showSettings() {
+    openPreferences("paneSearch", { origin: "contentSearch" });
 
-      <method name="observe">
-        <parameter name="aEngine"/>
-        <parameter name="aTopic"/>
-        <parameter name="aData"/>
-        <body><![CDATA[
-          // Make sure the engine list is refetched next time it's needed.
-          this._engines = null;
-        ]]></body>
-      </method>
-
-      <method name="showSettings">
-        <body><![CDATA[
-          openPreferences("paneSearch", {origin: "contentSearch"});
-
-          // If the preference tab was already selected, the panel doesn't
-          // close itself automatically.
-          this.popup.hidePopup();
-        ]]></body>
-      </method>
+    // 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. -->
-      <method name="_updateAfterQueryChanged">
-        <body><![CDATA[
-          let headerSearchText =
-            document.getAnonymousElementByAttribute(this, "anonid",
-                                                    "searchbar-oneoffheader-searchtext");
-          headerSearchText.setAttribute("value", this.query);
-          let groupText;
-          let isOneOffSelected =
-            this.selectedButton &&
-            this.selectedButton.classList.contains("searchbar-engine-one-off-item");
-          // Typing de-selects the settings or opensearch buttons at the bottom
-          // of the search panel, as typing shows the user intends to search.
-          if (this.selectedButton && !isOneOffSelected)
-            this.selectedButton = null;
-          if (this.query) {
-            groupText = headerSearchText.previousElementSibling.value +
-                        '"' + headerSearchText.value + '"' +
-                        headerSearchText.nextElementSibling.value;
-            if (!isOneOffSelected)
-              this.header.selectedIndex = 1;
-          } else {
-            let noSearchHeader =
-              document.getAnonymousElementByAttribute(this, "anonid",
-                                                      "searchbar-oneoffheader-search");
-            groupText = noSearchHeader.value;
-            if (!isOneOffSelected)
-              this.header.selectedIndex = 0;
-          }
-          this.buttons.setAttribute("aria-label", groupText);
-        ]]></body>
-      </method>
+  /**
+   * 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 =
+      this.selectedButton &&
+      this.selectedButton.classList.contains("searchbar-engine-one-off-item");
+    // Typing de-selects the settings or opensearch buttons at the bottom
+    // of the search panel, as typing shows the user intends to search.
+    if (this.selectedButton && !isOneOffSelected) {
+      this.selectedButton = null;
+    }
+    if (this.query) {
+      groupText = headerSearchText.previousElementSibling.value +
+                  '"' + headerSearchText.value + '"' +
+                  headerSearchText.nextElementSibling.value;
+      if (!isOneOffSelected) {
+        this.header.selectedIndex = 1;
+      }
+    } else {
+      let noSearchHeader = this.querySelector(".searchbar-oneoffheader-search");
+      groupText = noSearchHeader.value;
+      if (!isOneOffSelected) {
+        this.header.selectedIndex = 0;
+      }
+    }
+    this.buttons.setAttribute("aria-label", groupText);
+  }
+
+  /**
+   * Builds all the UI.
+   */
+  _rebuild() {
+    // Update the 'Search for <keywords> with:" header.
+    this._updateAfterQueryChanged();
+
+    // Handle opensearch items. This needs to be done before building the
+    // list of one off providers, as that code will return early if all the
+    // alternative engines are hidden.
+    // Skip this in compact mode, ie. for the urlbar.
+    if (!this.compact) {
+      this._rebuildAddEngineList();
+    }
 
-      <field name="_engines">null</field>
-      <property name="engines" readonly="true">
-        <getter><![CDATA[
-          if (this._engines)
-            return this._engines;
-          let currentEngineNameToIgnore;
-          if (!this.getAttribute("includecurrentengine"))
-            currentEngineNameToIgnore = Services.search.currentEngine.name;
-
-          let pref = Services.prefs.getStringPref("browser.search.hiddenOneOffs");
-          let hiddenList = pref ? pref.split(",") : [];
+    // Check if the one-off buttons really need to be rebuilt.
+    if (this._textbox) {
+      // We can't get a reliable value for the popup width without flushing,
+      // but the popup width won't change if the textbox width doesn't.
+      let DOMUtils = window.windowUtils;
+      let textboxWidth = DOMUtils.getBoundsWithoutFlushing(this._textbox).width;
+      // We can return early if neither the list of engines nor the panel
+      // width has changed.
+      if (this._engines && this._textboxWidth == textboxWidth) {
+        return;
+      }
+      this._textboxWidth = textboxWidth;
+    }
 
-          this._engines = Services.search.getVisibleEngines().filter(e => {
-            let name = e.name;
-            return (!currentEngineNameToIgnore ||
-                    name != currentEngineNameToIgnore) &&
-                   !hiddenList.includes(name);
-          });
+    // Finally, build the list of one-off buttons.
+    while (this.buttons.firstElementChild != this.settingsButtonCompact) {
+      this.buttons.firstElementChild.remove();
+    }
 
-          return this._engines;
-        ]]></getter>
-      </property>
+    // Remove the trailing empty text node introduced by the binding's
+    // content markup above.
+    if (this.settingsButtonCompact.nextElementSibling) {
+      this.settingsButtonCompact.nextElementSibling.remove();
+    }
 
-      <!-- Builds all the UI. -->
-      <method name="_rebuild">
-        <body><![CDATA[
-          // Update the 'Search for <keywords> with:" header.
-          this._updateAfterQueryChanged();
+    let engines = this.engines;
+    let oneOffCount = engines.length;
+    let collapsed = !oneOffCount ||
+                    (oneOffCount == 1 && engines[0].name == Services.search.currentEngine.name);
+
+    // header is a xul:deck so collapsed doesn't work on it, see bug 589569.
+    this.header.hidden = this.buttons.collapsed = collapsed;
 
-          // Handle opensearch items. This needs to be done before building the
-          // list of one off providers, as that code will return early if all the
-          // alternative engines are hidden.
-          // Skip this in compact mode, ie. for the urlbar.
-          if (!this.compact)
-            this._rebuildAddEngineList();
+    if (collapsed) {
+      return;
+    }
+
+    let panelWidth = parseInt(this.popup.clientWidth);
+
+    // There's one weird thing to guard against: when layout pixels
+    // aren't an integral multiple of device pixels, the last button
+    // of each row sometimes gets pushed to the next row, depending on the
+    // panel and button widths.
+    // This is likely because the clientWidth getter rounds the value, but
+    // the panel's border width is not an integer.
+    // As a workaround, decrement the width if the scale is not an integer.
+    let scale = window.windowUtils.screenPixelsPerCSSPixel;
+    if (Math.floor(scale) != scale) {
+      --panelWidth;
+    }
 
-          // Check if the one-off buttons really need to be rebuilt.
-          if (this._textbox) {
-            // We can't get a reliable value for the popup width without flushing,
-            // but the popup width won't change if the textbox width doesn't.
-            let DOMUtils = window.windowUtils;
-            let textboxWidth =
-              DOMUtils.getBoundsWithoutFlushing(this._textbox).width;
-            // We can return early if neither the list of engines nor the panel
-            // width has changed.
-            if (this._engines && this._textboxWidth == textboxWidth) {
-              return;
-            }
-            this._textboxWidth = textboxWidth;
-          }
+    // The + 1 is because the last button doesn't have a right border.
+    let enginesPerRow = Math.floor((panelWidth + 1) / this.buttonWidth);
+    let buttonWidth = Math.floor(panelWidth / enginesPerRow);
+    // There will be an emtpy area of:
+    //   panelWidth - enginesPerRow * buttonWidth  px
+    // at the end of each row.
 
-          // Finally, build the list of one-off buttons.
-          while (this.buttons.firstElementChild != this.settingsButtonCompact)
-            this.buttons.firstElementChild.remove();
-          // Remove the trailing empty text node introduced by the binding's
-          // content markup above.
-          if (this.settingsButtonCompact.nextElementSibling)
-            this.settingsButtonCompact.nextElementSibling.remove();
+    // If the <description> tag with the list of search engines doesn't have
+    // a fixed height, the panel will be sized incorrectly, causing the bottom
+    // of the suggestion <tree> to be hidden.
+    if (this.compact) {
+      ++oneOffCount;
+    }
+    let rowCount = Math.ceil(oneOffCount / enginesPerRow);
+    let height = rowCount * 33; // 32px per row, 1px border.
+    this.buttons.setAttribute("height", height + "px");
 
-          let engines = this.engines;
-          let oneOffCount = engines.length;
-          let collapsed = !oneOffCount ||
-                          (oneOffCount == 1 && engines[0].name == Services.search.currentEngine.name);
+    // Ensure we can refer to the settings buttons by ID:
+    let origin = this.telemetryOrigin;
+    this.settingsButton.id = origin + "-anon-search-settings";
+    this.settingsButtonCompact.id = origin + "-anon-search-settings-compact";
 
-          // header is a xul:deck so collapsed doesn't work on it, see bug 589569.
-          this.header.hidden = this.buttons.collapsed = collapsed;
-
-          if (collapsed)
-            return;
-
-          let panelWidth = parseInt(this.popup.clientWidth);
+    let dummyItems = enginesPerRow - (oneOffCount % enginesPerRow || enginesPerRow);
+    for (let i = 0; i < engines.length; ++i) {
+      let engine = engines[i];
+      let button = document.createXULElement("button");
+      button.id = this._buttonIDForEngine(engine);
+      let uri = "chrome://browser/skin/search-engine-placeholder.png";
+      if (engine.iconURI) {
+        uri = engine.iconURI.spec;
+      }
+      button.setAttribute("image", uri);
+      button.setAttribute("class", "searchbar-engine-one-off-item");
+      button.setAttribute("tooltiptext", engine.name);
+      button.setAttribute("width", buttonWidth);
+      button.engine = engine;
 
-          // There's one weird thing to guard against: when layout pixels
-          // aren't an integral multiple of device pixels, the last button
-          // of each row sometimes gets pushed to the next row, depending on the
-          // panel and button widths.
-          // This is likely because the clientWidth getter rounds the value, but
-          // the panel's border width is not an integer.
-          // As a workaround, decrement the width if the scale is not an integer.
-          let scale = window.windowUtils.screenPixelsPerCSSPixel;
-          if (Math.floor(scale) != scale) {
-            --panelWidth;
-          }
+      if ((i + 1) % enginesPerRow == 0) {
+        button.classList.add("last-of-row");
+      }
 
-          // The + 1 is because the last button doesn't have a right border.
-          let enginesPerRow = Math.floor((panelWidth + 1) / this.buttonWidth);
-          let buttonWidth = Math.floor(panelWidth / enginesPerRow);
-          // There will be an emtpy area of:
-          //   panelWidth - enginesPerRow * buttonWidth  px
-          // at the end of each row.
+      if (i + 1 == engines.length) {
+        button.classList.add("last-engine");
+      }
+
+      if (i >= oneOffCount + dummyItems - enginesPerRow) {
+        button.classList.add("last-row");
+      }
+
+      this.buttons.insertBefore(button, this.settingsButtonCompact);
+    }
 
-          // If the <description> tag with the list of search engines doesn't have
-          // a fixed height, the panel will be sized incorrectly, causing the bottom
-          // of the suggestion <tree> to be hidden.
-          if (this.compact)
-            ++oneOffCount;
-          let rowCount = Math.ceil(oneOffCount / enginesPerRow);
-          let height = rowCount * 33; // 32px per row, 1px border.
-          this.buttons.setAttribute("height", height + "px");
+    let hasDummyItems = !!dummyItems;
+    while (dummyItems) {
+      let button = document.createXULElement("button");
+      button.setAttribute("class", "searchbar-engine-one-off-item dummy last-row");
+      button.setAttribute("width", buttonWidth);
 
-          // Ensure we can refer to the settings buttons by ID:
-          let origin = this.telemetryOrigin;
-          this.settingsButton.id = origin + "-anon-search-settings";
-          this.settingsButtonCompact.id = origin + "-anon-search-settings-compact";
+      if (!--dummyItems) {
+        button.classList.add("last-of-row");
+      }
+
+      this.buttons.insertBefore(button, this.settingsButtonCompact);
+    }
 
-          let dummyItems = enginesPerRow - (oneOffCount % enginesPerRow || enginesPerRow);
-          for (let i = 0; i < engines.length; ++i) {
-            let engine = engines[i];
-            let button = document.createXULElement("button");
-            button.id = this._buttonIDForEngine(engine);
-            let uri = "chrome://browser/skin/search-engine-placeholder.png";
-            if (engine.iconURI) {
-              uri = engine.iconURI.spec;
-            }
-            button.setAttribute("image", uri);
-            button.setAttribute("class", "searchbar-engine-one-off-item");
-            button.setAttribute("tooltiptext", engine.name);
-            button.setAttribute("width", buttonWidth);
-            button.engine = engine;
+    if (this.compact) {
+      this.settingsButtonCompact.setAttribute("width", buttonWidth);
+      if (rowCount == 1 && hasDummyItems) {
+        // When there's only one row, make the compact settings button
+        // hug the right edge of the panel.  It may not due to the panel's
+        // width not being an integral multiple of the button width.  (See
+        // the "There will be an emtpy area" comment above.)  Increase the
+        // width of the last dummy item by the remainder.
+        let remainder = panelWidth - (enginesPerRow * buttonWidth);
+        let width = remainder + buttonWidth;
+        let lastDummyItem = this.settingsButtonCompact.previousElementSibling;
+        lastDummyItem.setAttribute("width", width);
+      }
+    }
+  }
 
-            if ((i + 1) % enginesPerRow == 0)
-              button.classList.add("last-of-row");
-
-            if (i + 1 == engines.length)
-              button.classList.add("last-engine");
-
-            if (i >= oneOffCount + dummyItems - enginesPerRow)
-              button.classList.add("last-row");
+  _rebuildAddEngineList() {
+    let list = this.addEngines;
+    while (list.firstChild) {
+      list.firstChild.remove();
+    }
 
-            this.buttons.insertBefore(button, this.settingsButtonCompact);
-          }
-
-          let hasDummyItems = !!dummyItems;
-          while (dummyItems) {
-            let button = document.createXULElement("button");
-            button.setAttribute("class", "searchbar-engine-one-off-item dummy last-row");
-            button.setAttribute("width", buttonWidth);
+    // Add a button for each engine that the page in the selected browser
+    // offers, except when there are too many offered engines.
+    // The popup isn't designed to handle too many (by scrolling for
+    // example), so a page could break the popup by offering too many.
+    // Instead, add a single menu button with a submenu of all the engines.
 
-            if (!--dummyItems)
-              button.classList.add("last-of-row");
+    if (!gBrowser.selectedBrowser.engines) {
+      return;
+    }
 
-            this.buttons.insertBefore(button, this.settingsButtonCompact);
-          }
+    let engines = gBrowser.selectedBrowser.engines;
+    let tooManyEngines = engines.length > this._addEngineMenuThreshold;
 
-          if (this.compact) {
-            this.settingsButtonCompact.setAttribute("width", buttonWidth);
-            if (rowCount == 1 && hasDummyItems) {
-              // When there's only one row, make the compact settings button
-              // hug the right edge of the panel.  It may not due to the panel's
-              // width not being an integral multiple of the button width.  (See
-              // the "There will be an emtpy area" comment above.)  Increase the
-              // width of the last dummy item by the remainder.
-              let remainder = panelWidth - (enginesPerRow * buttonWidth);
-              let width = remainder + buttonWidth;
-              let lastDummyItem = this.settingsButtonCompact.previousElementSibling;
-              lastDummyItem.setAttribute("width", width);
-            }
-          }
-        ]]></body>
-      </method>
+    if (tooManyEngines) {
+      // Make the top-level menu button.
+      let button = document.createXULElement("toolbarbutton");
+      list.appendChild(button);
+      button.classList.add("addengine-item", "badged-button");
+      button.setAttribute("class", "addengine-menu-button");
+      button.setAttribute("type", "menu");
+      button.setAttribute("label",
+        this.bundle.GetStringFromName("cmd_addFoundEngineMenu"));
+      button.setAttribute("crop", "end");
+      button.setAttribute("pack", "start");
 
-      <!-- If a page offers more than this number of engines, the add-engines
-           menu button is shown, instead of showing the engines directly in the
-           popup. -->
-      <field name="_addEngineMenuThreshold">5</field>
+      // Set the menu button's image to the image of the first engine.  The
+      // offered engines may have differing images, so there's no perfect
+      // choice here.
+      let engine = engines[0];
+      if (engine.icon) {
+        button.setAttribute("image", engine.icon);
+      }
 
-      <method name="_rebuildAddEngineList">
-        <body><![CDATA[
-        let list = this.addEngines;
-        while (list.firstChild) {
-          list.firstChild.remove();
-        }
+      // Now make the button's child menupopup.
+      list = document.createXULElement("menupopup");
+      button.appendChild(list);
+      list.setAttribute("class", "addengine-menu");
+      list.setAttribute("position", "topright topleft");
 
-        // Add a button for each engine that the page in the selected browser
-        // offers, except when there are too many offered engines.
-        // The popup isn't designed to handle too many (by scrolling for
-        // example), so a page could break the popup by offering too many.
-        // Instead, add a single menu button with a submenu of all the engines.
-
-        if (!gBrowser.selectedBrowser.engines) {
-          return;
-        }
-
-        let engines = gBrowser.selectedBrowser.engines;
-        let tooManyEngines = engines.length > this._addEngineMenuThreshold;
-
-        if (tooManyEngines) {
-          // Make the top-level menu button.
-          let button = document.createXULElement("toolbarbutton");
-          list.appendChild(button);
-          button.classList.add("addengine-item", "badged-button");
-          button.setAttribute("anonid", "addengine-menu-button");
-          button.setAttribute("type", "menu");
-          button.setAttribute("label",
-            this.bundle.GetStringFromName("cmd_addFoundEngineMenu"));
-          button.setAttribute("crop", "end");
-          button.setAttribute("pack", "start");
+      // Events from child menupopups bubble up to the autocomplete binding,
+      // which breaks it, so prevent these events from propagating.
+      let suppressEventTypes = [
+        "popupshowing",
+        "popuphiding",
+        "popupshown",
+        "popuphidden",
+      ];
+      for (let type of suppressEventTypes) {
+        list.addEventListener(type, event => {
+          event.stopPropagation();
+        });
+      }
+    }
 
-          // Set the menu button's image to the image of the first engine.  The
-          // offered engines may have differing images, so there's no perfect
-          // choice here.
-          let engine = engines[0];
-          if (engine.icon) {
-            button.setAttribute("image", engine.icon);
-          }
-
-          // Now make the button's child menupopup.
-          list = document.createXULElement("menupopup");
-          button.appendChild(list);
-          list.setAttribute("anonid", "addengine-menu");
-          list.setAttribute("position", "topright topleft");
+    // Finally, add the engines to the list.  If there aren't too many
+    // engines, the list is the search-add-engines vbox.  Otherwise it's the
+    // menupopup created earlier.  In the latter case, create menuitem
+    // elements instead of buttons, because buttons don't get keyboard
+    // handling for free inside menupopups.
+    let eltType = tooManyEngines ? "menuitem" : "toolbarbutton";
+    for (let engine of engines) {
+      let button = document.createXULElement(eltType);
+      button.classList.add("addengine-item");
+      if (!tooManyEngines) {
+        button.classList.add("badged-button");
+      }
+      button.id = this.telemetryOrigin + "-add-engine-" +
+        this._fixUpEngineNameForID(engine.title);
+      let label = this.bundle.formatStringFromName("cmd_addFoundEngine", [engine.title], 1);
+      button.setAttribute("label", label);
+      button.setAttribute("crop", "end");
+      button.setAttribute("tooltiptext", engine.title + "\n" + engine.uri);
+      button.setAttribute("uri", engine.uri);
+      button.setAttribute("title", engine.title);
+      if (engine.icon) {
+        button.setAttribute("image", engine.icon);
+      }
+      if (tooManyEngines) {
+        button.classList.add("menuitem-iconic");
+      } else {
+        button.setAttribute("pack", "start");
+      }
+      list.appendChild(button);
+    }
+  }
 
-          // Events from child menupopups bubble up to the autocomplete binding,
-          // which breaks it, so prevent these events from propagating.
-          let suppressEventTypes = [
-            "popupshowing",
-            "popuphiding",
-            "popupshown",
-            "popuphidden",
-          ];
-          for (let type of suppressEventTypes) {
-            list.addEventListener(type, event => {
-              event.stopPropagation();
-            });
-          }
-        }
+  _buttonIDForEngine(engine) {
+    return this.telemetryOrigin + "-engine-one-off-item-" + this._fixUpEngineNameForID(engine.name);
+  }
+
+  _fixUpEngineNameForID(name) {
+    return name.replace(/ /g, "-");
+  }
+
+  _buttonForEngine(engine) {
+    return document.getElementById(this._buttonIDForEngine(engine));
+  }
 
-        // Finally, add the engines to the list.  If there aren't too many
-        // engines, the list is the add-engines vbox.  Otherwise it's the
-        // menupopup created earlier.  In the latter case, create menuitem
-        // elements instead of buttons, because buttons don't get keyboard
-        // handling for free inside menupopups.
-        let eltType = tooManyEngines ? "menuitem" : "toolbarbutton";
-        for (let engine of engines) {
-          let button = document.createXULElement(eltType);
-          button.classList.add("addengine-item");
-          if (!tooManyEngines) {
-            button.classList.add("badged-button");
-          }
-          button.id = this.telemetryOrigin + "-add-engine-" +
-                      this._fixUpEngineNameForID(engine.title);
-          let label = this.bundle.formatStringFromName("cmd_addFoundEngine",
-                                                       [engine.title], 1);
-          button.setAttribute("label", label);
-          button.setAttribute("crop", "end");
-          button.setAttribute("tooltiptext", engine.title + "\n" + engine.uri);
-          button.setAttribute("uri", engine.uri);
-          button.setAttribute("title", engine.title);
-          if (engine.icon) {
-            button.setAttribute("image", engine.icon);
-          }
-          if (tooManyEngines) {
-            button.classList.add("menuitem-iconic");
-          } else {
-            button.setAttribute("pack", "start");
-          }
-          list.appendChild(button);
+  /**
+   * Updates the popup and textbox for the currently selected or moused-over
+   * button.
+   *
+   * @param mousedOverButton
+   *        The currently moused-over button, or null if there isn't one.
+   */
+  _updateStateForButton(mousedOverButton) {
+    let button = mousedOverButton;
+
+    // Ignore dummy buttons.
+    if (button && button.classList.contains("dummy")) {
+      button = null;
+    }
+
+    // If there's no moused-over button, then the one-offs should reflect
+    // the selected button, if any.
+    button = button || this.selectedButton;
+
+    if (!button) {
+      this.header.selectedIndex = this.query ? 1 : 0;
+      if (this.textbox) {
+        this.textbox.removeAttribute("aria-activedescendant");
+      }
+      return;
+    }
+
+    if (button.classList.contains("searchbar-engine-one-off-item") && button.engine) {
+      let headerEngineText = this.querySelector(".searchbar-oneoffheader-engine");
+      this.header.selectedIndex = 2;
+      headerEngineText.value = button.engine.name;
+    } else {
+      this.header.selectedIndex = this.query ? 1 : 0;
+    }
+    if (this.textbox) {
+      this.textbox.setAttribute("aria-activedescendant", button.id);
+    }
+  }
+
+  getSelectableButtons(aIncludeNonEngineButtons) {
+    let buttons = [];
+    for (let oneOff = this.buttons.firstElementChild; oneOff; oneOff = oneOff.nextElementSibling) {
+      // oneOff may be a text node since the list xul:description contains
+      // whitespace and the compact settings button.  See the markup
+      // above.  _rebuild removes text nodes, but it may not have been
+      // called yet (because e.g. the popup hasn't been opened yet).
+      if (oneOff.nodeType == Node.ELEMENT_NODE) {
+        if (oneOff.classList.contains("dummy") ||
+            oneOff.classList.contains("search-setting-button-compact")) {
+          break;
         }
-        ]]></body>
-      </method>
-
-      <method name="_buttonIDForEngine">
-        <parameter name="engine"/>
-        <body><![CDATA[
-          return this.telemetryOrigin + "-engine-one-off-item-" +
-                 this._fixUpEngineNameForID(engine.name);
-        ]]></body>
-      </method>
+        buttons.push(oneOff);
+      }
+    }
 
-      <method name="_fixUpEngineNameForID">
-        <parameter name="name"/>
-        <body><![CDATA[
-          return name.replace(/ /g, "-");
-        ]]></body>
-      </method>
+    if (aIncludeNonEngineButtons) {
+      for (let addEngine = this.addEngines.firstElementChild; addEngine; addEngine = addEngine.nextElementSibling) {
+        buttons.push(addEngine);
+      }
+      buttons.push(this.compact ? this.settingsButtonCompact : this.settingsButton);
+    }
 
-      <method name="_buttonForEngine">
-        <parameter name="engine"/>
-        <body><![CDATA[
-          return document.getElementById(this._buttonIDForEngine(engine));
-        ]]></body>
-      </method>
+    return buttons;
+  }
+
+  handleSearchCommand(aEvent, aEngine, aForceNewTab) {
+    let where = "current";
+    let params;
 
-      <!--
-        Updates the popup and textbox for the currently selected or moused-over
-        button.
-
-        @param mousedOverButton
-               The currently moused-over button, or null if there isn't one.
-      -->
-      <method name="_updateStateForButton">
-        <parameter name="mousedOverButton"/>
-        <body><![CDATA[
-          let button = mousedOverButton;
-
-          // Ignore dummy buttons.
-          if (button && button.classList.contains("dummy")) {
-            button = null;
-          }
-
-          // If there's no moused-over button, then the one-offs should reflect
-          // the selected button, if any.
-          button = button || this.selectedButton;
+    // Open ctrl/cmd clicks on one-off buttons in a new background tab.
+    if (aForceNewTab) {
+      where = "tab";
+      if (Services.prefs.getBoolPref("browser.tabs.loadInBackground")) {
+        params = {
+          inBackground: true,
+        };
+      }
+    } else {
+      var newTabPref = Services.prefs.getBoolPref("browser.search.openintab");
+      if ((aEvent instanceof KeyboardEvent && aEvent.altKey) ^ newTabPref &&
+          !isTabEmpty(gBrowser.selectedTab)) {
+        where = "tab";
+      }
+      if (aEvent instanceof MouseEvent &&
+          (aEvent.button == 1 || aEvent.getModifierState("Accel"))) {
+        where = "tab";
+        params = {
+          inBackground: true,
+        };
+      }
+    }
 
-          if (!button) {
-            this.header.selectedIndex = this.query ? 1 : 0;
-            if (this.textbox) {
-              this.textbox.removeAttribute("aria-activedescendant");
-            }
-            return;
-          }
-
-          if (button.classList.contains("searchbar-engine-one-off-item") &&
-              button.engine) {
-            let headerEngineText =
-              document.getAnonymousElementByAttribute(this, "anonid",
-                                                      "searchbar-oneoffheader-engine");
-            this.header.selectedIndex = 2;
-            headerEngineText.value = button.engine.name;
-          } else {
-            this.header.selectedIndex = this.query ? 1 : 0;
-          }
-          if (this.textbox) {
-            this.textbox.setAttribute("aria-activedescendant", button.id);
-          }
-        ]]></body>
-      </method>
+    this.popup.handleOneOffSearch(aEvent, aEngine, where, params);
+  }
 
-      <method name="getSelectableButtons">
-        <parameter name="aIncludeNonEngineButtons"/>
-        <body><![CDATA[
-          let buttons = [];
-          for (let oneOff = this.buttons.firstElementChild; oneOff; oneOff = oneOff.nextElementSibling) {
-            // oneOff may be a text node since the list xul:description contains
-            // whitespace and the compact settings button.  See the markup
-            // above.  _rebuild removes text nodes, but it may not have been
-            // called yet (because e.g. the popup hasn't been opened yet).
-            if (oneOff.nodeType == Node.ELEMENT_NODE) {
-              if (oneOff.classList.contains("dummy") ||
-                  oneOff.classList.contains("search-setting-button-compact"))
-                break;
-              buttons.push(oneOff);
-            }
-          }
-
-          if (aIncludeNonEngineButtons) {
-            for (let addEngine = this.addEngines.firstElementChild; addEngine; addEngine = addEngine.nextElementSibling) {
-              buttons.push(addEngine);
-            }
-            buttons.push(this.compact ? this.settingsButtonCompact : this.settingsButton);
-          }
-
-          return buttons;
-        ]]></body>
-      </method>
+  /**
+   * Increments or decrements the index of the currently selected one-off.
+   *
+   * @param aForward
+   *        If true, the index is incremented, and if false, the index is
+   *        decremented.
+   * @param aIncludeNonEngineButtons
+   *        If true, non-dummy buttons that do not have engines are included.
+   *        These buttons include the OpenSearch and settings buttons.  For
+   *        example, if the currently selected button is an engine button,
+   *        the next button is the settings button, and you pass true for
+   *        aForward, then passing true for this value would cause the
+   *        settings to be selected.  Passing false for this value would
+   *        cause the selection to clear or wrap around, depending on what
+   *        value you passed for the aWrapAround parameter.
+   * @param aWrapAround
+   *        If true, the selection wraps around between the first and last
+   *        buttons.
+   * @return True if the selection can continue to advance after this method
+   *         returns and false if not.
+   */
+  advanceSelection(aForward, aIncludeNonEngineButtons, aWrapAround) {
+    let buttons = this.getSelectableButtons(aIncludeNonEngineButtons);
+    let index;
+    if (this.selectedButton) {
+      let inc = aForward ? 1 : -1;
+      let oldIndex = buttons.indexOf(this.selectedButton);
+      index = (oldIndex + inc + buttons.length) % buttons.length;
+      if (!aWrapAround &&
+          ((aForward && index <= oldIndex) ||
+           (!aForward && oldIndex <= index))) {
+        // The index has wrapped around, but wrapping around isn't
+        // allowed.
+        index = -1;
+      }
+    } else {
+      index = aForward ? 0 : buttons.length - 1;
+    }
+    this.selectedButton = index < 0 ? null : buttons[index];
+  }
 
-      <method name="handleSearchCommand">
-        <parameter name="aEvent"/>
-        <parameter name="aEngine"/>
-        <parameter name="aForceNewTab"/>
-        <body><![CDATA[
-          let where = "current";
-          let params;
-
-          // Open ctrl/cmd clicks on one-off buttons in a new background tab.
-          if (aForceNewTab) {
-            where = "tab";
-            if (Services.prefs.getBoolPref("browser.tabs.loadInBackground")) {
-              params = {
-                inBackground: true,
-              };
-            }
-          } else {
-            var newTabPref = Services.prefs.getBoolPref("browser.search.openintab");
-            if (((aEvent instanceof KeyboardEvent && aEvent.altKey) ^ newTabPref) &&
-                !isTabEmpty(gBrowser.selectedTab)) {
-              where = "tab";
-            }
-            if ((aEvent instanceof MouseEvent) &&
-                (aEvent.button == 1 || aEvent.getModifierState("Accel"))) {
-              where = "tab";
-              params = {
-                inBackground: true,
-              };
-            }
-          }
-
-          this.popup.handleOneOffSearch(aEvent, aEngine, where, params);
-        ]]></body>
-      </method>
-
-      <!--
-        Increments or decrements the index of the currently selected one-off.
+  /**
+   * This handles key presses specific to the one-off buttons like Tab and
+   * Alt+Up/Down, and Up/Down keys within the buttons.  Since one-off buttons
+   * are always used in conjunction with a list of some sort (in this.popup),
+   * it also handles Up/Down keys that cross the boundaries between list
+   * items and the one-off buttons.
+   *
+   * If this method handles the key press, then event.defaultPrevented will
+   * be true when it returns.
+   *
+   * @param event
+   *        The key event.
+   * @param numListItems
+   *        The number of items in the list.  The reason that this is a
+   *        parameter at all is that the list may contain items at the end
+   *        that should be ignored, depending on the consumer.  That's true
+   *        for the urlbar for example.
+   * @param allowEmptySelection
+   *        Pass true if it's OK that neither the list nor the one-off
+   *        buttons contains a selection.  Pass false if either the list or
+   *        the one-off buttons (or both) should always contain a selection.
+   * @param textboxUserValue
+   *        When the last list item is selected and the user presses Down,
+   *        the first one-off becomes selected and the textbox value is
+   *        restored to the value that the user typed.  Pass that value here.
+   *        However, if you pass true for allowEmptySelection, you don't need
+   *        to pass anything for this parameter.  (Pass undefined or null.)
+   */
+  handleKeyPress(event, numListItems, allowEmptySelection, textboxUserValue) {
+    if (!this.popup) {
+      return;
+    }
+    let handled = this._handleKeyPress(event, numListItems,
+      allowEmptySelection,
+      textboxUserValue);
+    if (handled) {
+      event.preventDefault();
+      event.stopPropagation();
+    }
+  }
 
-        @param aForward
-               If true, the index is incremented, and if false, the index is
-               decremented.
-        @param aIncludeNonEngineButtons
-               If true, non-dummy buttons that do not have engines are included.
-               These buttons include the OpenSearch and settings buttons.  For
-               example, if the currently selected button is an engine button,
-               the next button is the settings button, and you pass true for
-               aForward, then passing true for this value would cause the
-               settings to be selected.  Passing false for this value would
-               cause the selection to clear or wrap around, depending on what
-               value you passed for the aWrapAround parameter.
-        @param aWrapAround
-               If true, the selection wraps around between the first and last
-               buttons.
-        @return True if the selection can continue to advance after this method
-                returns and false if not.
-      -->
-      <method name="advanceSelection">
-        <parameter name="aForward"/>
-        <parameter name="aIncludeNonEngineButtons"/>
-        <parameter name="aWrapAround"/>
-        <body><![CDATA[
-          let buttons = this.getSelectableButtons(aIncludeNonEngineButtons);
-          let index;
-          if (this.selectedButton) {
-            let inc = aForward ? 1 : -1;
-            let oldIndex = buttons.indexOf(this.selectedButton);
-            index = ((oldIndex + inc) + buttons.length) % buttons.length;
-            if (!aWrapAround &&
-                ((aForward && index <= oldIndex) ||
-                 (!aForward && oldIndex <= index))) {
-              // The index has wrapped around, but wrapping around isn't
-              // allowed.
-              index = -1;
-            }
-          } else {
-            index = aForward ? 0 : buttons.length - 1;
-          }
-          this.selectedButton = index < 0 ? null : buttons[index];
-        ]]></body>
-      </method>
+  _handleKeyPress(event, numListItems, allowEmptySelection, textboxUserValue) {
+    if (this.compact && this.buttons.collapsed) {
+      return false;
+    }
+    if (event.keyCode == KeyEvent.DOM_VK_RIGHT &&
+        this.selectedButton &&
+        this.selectedButton.classList.contains("addengine-menu-button")) {
+      // If the add-engine overflow menu item is selected and the user
+      // presses the right arrow key, open the submenu.  Unfortunately
+      // handling the left arrow key -- to close the popup -- isn't
+      // straightforward.  Once the popup is open, it consumes all key
+      // events.  Setting ignorekeys=handled on it doesn't help, since the
+      // popup handles all arrow keys.  Setting ignorekeys=true on it does
+      // mean that the popup no longer consumes the left arrow key, but
+      // then it no longer handles up/down keys to select items in the
+      // popup.
+      this.selectedButton.open = true;
+      return true;
+    }
 
-      <!--
-        This handles key presses specific to the one-off buttons like Tab and
-        Alt+Up/Down, and Up/Down keys within the buttons.  Since one-off buttons
-        are always used in conjunction with a list of some sort (in this.popup),
-        it also handles Up/Down keys that cross the boundaries between list
-        items and the one-off buttons.
-
-        If this method handles the key press, then event.defaultPrevented will
-        be true when it returns.
+    // Handle the Tab key, but only if non-Shift modifiers aren't also
+    // pressed to avoid clobbering other shortcuts (like the Alt+Tab
+    // browser tab switcher).  The reason this uses getModifierState() and
+    // checks for "AltGraph" is that when you press Shift-Alt-Tab,
+    // event.altKey is actually false for some reason, at least on macOS.
+    // getModifierState("Alt") is also false, but "AltGraph" is true.
+    if (event.keyCode == KeyEvent.DOM_VK_TAB &&
+        !event.getModifierState("Alt") &&
+        !event.getModifierState("AltGraph") &&
+        !event.getModifierState("Control") &&
+        !event.getModifierState("Meta")) {
+      if (this.getAttribute("disabletab") == "true" ||
+          (event.shiftKey && this.selectedButtonIndex <= 0) ||
+          (!event.shiftKey && this.selectedButtonIndex == this.getSelectableButtons(true).length - 1)) {
+        this.selectedButton = null;
+        return false;
+      }
+      this.popup.selectedIndex = -1;
+      this.advanceSelection(!event.shiftKey, true, false);
+      return !!this.selectedButton;
+    }
 
-        @param event
-               The key event.
-        @param numListItems
-               The number of items in the list.  The reason that this is a
-               parameter at all is that the list may contain items at the end
-               that should be ignored, depending on the consumer.  That's true
-               for the urlbar for example.
-        @param allowEmptySelection
-               Pass true if it's OK that neither the list nor the one-off
-               buttons contains a selection.  Pass false if either the list or
-               the one-off buttons (or both) should always contain a selection.
-        @param textboxUserValue
-               When the last list item is selected and the user presses Down,
-               the first one-off becomes selected and the textbox value is
-               restored to the value that the user typed.  Pass that value here.
-               However, if you pass true for allowEmptySelection, you don't need
-               to pass anything for this parameter.  (Pass undefined or null.)
-      -->
-      <method name="handleKeyPress">
-        <parameter name="event"/>
-        <parameter name="numListItems"/>
-        <parameter name="allowEmptySelection"/>
-        <parameter name="textboxUserValue"/>
-        <body><![CDATA[
-          if (!this.popup) {
-            return;
-          }
-          let handled = this._handleKeyPress(event, numListItems,
-                                             allowEmptySelection,
-                                             textboxUserValue);
-          if (handled) {
-            event.preventDefault();
-            event.stopPropagation();
-          }
-        ]]></body>
-      </method>
+    if (event.keyCode == KeyboardEvent.DOM_VK_UP) {
+      if (event.altKey) {
+        // Keep the currently selected result in the list (if any) as a
+        // secondary "alt" selection and move the selection up within the
+        // buttons.
+        this.advanceSelection(false, false, false);
+        return true;
+      }
+      if (numListItems == 0) {
+        this.advanceSelection(false, true, false);
+        return true;
+      }
+      if (this.popup.selectedIndex > 0) {
+        // Moving up within the list.  The autocomplete controller should
+        // handle this case.  A button may be selected, so null it.
+        this.selectedButton = null;
+        return false;
+      }
+      if (this.popup.selectedIndex == 0) {
+        // Moving up from the top of the list.
+        if (allowEmptySelection) {
+          // Let the autocomplete controller remove selection in the list
+          // and revert the typed text in the textbox.
+          return false;
+        }
+        // Wrap selection around to the last button.
+        if (this.textbox && typeof textboxUserValue == "string") {
+          this.textbox.value = textboxUserValue;
+        }
+        this.advanceSelection(false, true, true);
+        return true;
+      }
+      if (!this.selectedButton) {
+        // Moving up from no selection in the list or the buttons, back
+        // down to the last button.
+        this.advanceSelection(false, true, true);
+        return true;
+      }
+      if (this.selectedButtonIndex == 0) {
+        // Moving up from the buttons to the bottom of the list.
+        this.selectedButton = null;
+        return false;
+      }
+      // Moving up/left within the buttons.
+      this.advanceSelection(false, true, false);
+      return true;
+    }
 
-      <method name="_handleKeyPress">
-        <parameter name="event"/>
-        <parameter name="numListItems"/>
-        <parameter name="allowEmptySelection"/>
-        <parameter name="textboxUserValue"/>
-        <body><![CDATA[
-          if (this.compact && this.buttons.collapsed)
-            return false;
-          if (event.keyCode == KeyEvent.DOM_VK_RIGHT &&
-              this.selectedButton &&
-              this.selectedButton.getAttribute("anonid") ==
-                "addengine-menu-button") {
-            // If the add-engine overflow menu item is selected and the user
-            // presses the right arrow key, open the submenu.  Unfortunately
-            // handling the left arrow key -- to close the popup -- isn't
-            // straightforward.  Once the popup is open, it consumes all key
-            // events.  Setting ignorekeys=handled on it doesn't help, since the
-            // popup handles all arrow keys.  Setting ignorekeys=true on it does
-            // mean that the popup no longer consumes the left arrow key, but
-            // then it no longer handles up/down keys to select items in the
-            // popup.
-            this.selectedButton.open = true;
+    if (event.keyCode == KeyboardEvent.DOM_VK_DOWN) {
+      if (event.altKey) {
+        // Keep the currently selected result in the list (if any) as a
+        // secondary "alt" selection and move the selection down within
+        // the buttons.
+        this.advanceSelection(true, false, false);
+        return true;
+      }
+      if (numListItems == 0) {
+        this.advanceSelection(true, true, false);
+        return true;
+      }
+      if (this.popup.selectedIndex >= 0 &&
+          this.popup.selectedIndex < numListItems - 1) {
+        // Moving down within the list.  The autocomplete controller
+        // should handle this case.  A button may be selected, so null it.
+        this.selectedButton = null;
+        return false;
+      }
+      if (this.popup.selectedIndex == numListItems - 1) {
+        // Moving down from the last item in the list to the buttons.
+        this.selectedButtonIndex = 0;
+        if (allowEmptySelection) {
+          // Let the autocomplete controller remove selection in the list
+          // and revert the typed text in the textbox.
+          return false;
+        }
+        if (this.textbox && typeof textboxUserValue == "string") {
+          this.textbox.value = textboxUserValue;
+        }
+        this.popup.selectedIndex = -1;
+        return true;
+      }
+      if (this.selectedButton) {
+        let buttons = this.getSelectableButtons(true);
+        if (this.selectedButtonIndex == buttons.length - 1) {
+          // Moving down from the buttons back up to the top of the list.
+          this.selectedButton = null;
+          if (allowEmptySelection) {
+            // Prevent the selection from wrapping around to the top of
+            // the list by returning true, since the list currently has no
+            // selection.  Nothing should be selected after handling this
+            // Down key.
             return true;
           }
-
-          // Handle the Tab key, but only if non-Shift modifiers aren't also
-          // pressed to avoid clobbering other shortcuts (like the Alt+Tab
-          // browser tab switcher).  The reason this uses getModifierState() and
-          // checks for "AltGraph" is that when you press Shift-Alt-Tab,
-          // event.altKey is actually false for some reason, at least on macOS.
-          // getModifierState("Alt") is also false, but "AltGraph" is true.
-          if (event.keyCode == KeyEvent.DOM_VK_TAB &&
-              !event.getModifierState("Alt") &&
-              !event.getModifierState("AltGraph") &&
-              !event.getModifierState("Control") &&
-              !event.getModifierState("Meta")) {
-            if (this.getAttribute("disabletab") == "true" ||
-                (event.shiftKey &&
-                  this.selectedButtonIndex <= 0) ||
-                (!event.shiftKey &&
-                 this.selectedButtonIndex ==
-                   this.getSelectableButtons(true).length - 1)) {
-              this.selectedButton = null;
-              return false;
-            }
-            this.popup.selectedIndex = -1;
-            this.advanceSelection(!event.shiftKey, true, false);
-            return !!this.selectedButton;
-          }
+          return false;
+        }
+        // Moving down/right within the buttons.
+        this.advanceSelection(true, true, false);
+        return true;
+      }
+      return false;
+    }
 
-          if (event.keyCode == KeyboardEvent.DOM_VK_UP) {
-            if (event.altKey) {
-              // Keep the currently selected result in the list (if any) as a
-              // secondary "alt" selection and move the selection up within the
-              // buttons.
-              this.advanceSelection(false, false, false);
-              return true;
-            }
-            if (numListItems == 0) {
-              this.advanceSelection(false, true, false);
-              return true;
-            }
-            if (this.popup.selectedIndex > 0) {
-              // Moving up within the list.  The autocomplete controller should
-              // handle this case.  A button may be selected, so null it.
-              this.selectedButton = null;
-              return false;
-            }
-            if (this.popup.selectedIndex == 0) {
-              // Moving up from the top of the list.
-              if (allowEmptySelection) {
-                // Let the autocomplete controller remove selection in the list
-                // and revert the typed text in the textbox.
-                return false;
-              }
-              // Wrap selection around to the last button.
-              if (this.textbox && typeof(textboxUserValue) == "string") {
-                this.textbox.value = textboxUserValue;
-              }
-              this.advanceSelection(false, true, true);
-              return true;
-            }
-            if (!this.selectedButton) {
-              // Moving up from no selection in the list or the buttons, back
-              // down to the last button.
-              this.advanceSelection(false, true, true);
-              return true;
-            }
-            if (this.selectedButtonIndex == 0) {
-              // Moving up from the buttons to the bottom of the list.
-              this.selectedButton = null;
-              return false;
-            }
-            // Moving up/left within the buttons.
-            this.advanceSelection(false, true, false);
-            return true;
-          }
+    if (event.keyCode == KeyboardEvent.DOM_VK_LEFT) {
+      if (this.selectedButton && (this.compact || this.selectedButton.engine)) {
+        // Moving left within the buttons.
+        this.advanceSelection(false, this.compact, true);
+        return true;
+      }
+      return false;
+    }
 
-          if (event.keyCode == KeyboardEvent.DOM_VK_DOWN) {
-            if (event.altKey) {
-              // Keep the currently selected result in the list (if any) as a
-              // secondary "alt" selection and move the selection down within
-              // the buttons.
-              this.advanceSelection(true, false, false);
-              return true;
-            }
-            if (numListItems == 0) {
-              this.advanceSelection(true, true, false);
-              return true;
-            }
-            if (this.popup.selectedIndex >= 0 &&
-                this.popup.selectedIndex < numListItems - 1) {
-              // Moving down within the list.  The autocomplete controller
-              // should handle this case.  A button may be selected, so null it.
-              this.selectedButton = null;
-              return false;
-            }
-            if (this.popup.selectedIndex == numListItems - 1) {
-              // Moving down from the last item in the list to the buttons.
-              this.selectedButtonIndex = 0;
-              if (allowEmptySelection) {
-                // Let the autocomplete controller remove selection in the list
-                // and revert the typed text in the textbox.
-                return false;
-              }
-              if (this.textbox && typeof(textboxUserValue) == "string") {
-                this.textbox.value = textboxUserValue;
-              }
-              this.popup.selectedIndex = -1;
-              return true;
-            }
-            if (this.selectedButton) {
-              let buttons = this.getSelectableButtons(true);
-              if (this.selectedButtonIndex == buttons.length - 1) {
-                // Moving down from the buttons back up to the top of the list.
-                this.selectedButton = null;
-                if (allowEmptySelection) {
-                  // Prevent the selection from wrapping around to the top of
-                  // the list by returning true, since the list currently has no
-                  // selection.  Nothing should be selected after handling this
-                  // Down key.
-                  return true;
-                }
-                return false;
-              }
-              // Moving down/right within the buttons.
-              this.advanceSelection(true, true, false);
-              return true;
-            }
-            return false;
-          }
+    if (event.keyCode == KeyboardEvent.DOM_VK_RIGHT) {
+      if (this.selectedButton && (this.compact || this.selectedButton.engine)) {
+        // Moving right within the buttons.
+        this.advanceSelection(true, this.compact, true);
+        return true;
+      }
+      return false;
+    }
 
-          if (event.keyCode == KeyboardEvent.DOM_VK_LEFT) {
-            if (this.selectedButton &&
-                (this.compact || this.selectedButton.engine)) {
-              // Moving left within the buttons.
-              this.advanceSelection(false, this.compact, true);
-              return true;
-            }
-            return false;
-          }
+    return false;
+  }
 
-          if (event.keyCode == KeyboardEvent.DOM_VK_RIGHT) {
-            if (this.selectedButton &&
-                (this.compact || this.selectedButton.engine)) {
-              // Moving right within the buttons.
-              this.advanceSelection(true, this.compact, true);
-              return true;
-            }
-            return false;
-          }
-
-          return false;
-        ]]></body>
-      </method>
-
-      <!--
-        If the given event is related to the one-offs, this method records
-        one-off telemetry for it.  this.telemetryOrigin will be appended to the
-        computed source, so make sure you set that first.
-
-        @param aEvent
-               An event, like a click on a one-off button.
-        @param aOpenUILinkWhere
-               The "where" passed to openUILink.
-        @param aOpenUILinkParams
-               The "params" passed to openUILink.
-        @return True if telemetry was recorded and false if not.
-      -->
-      <method name="maybeRecordTelemetry">
-        <parameter name="aEvent"/>
-        <parameter name="aOpenUILinkWhere"/>
-        <parameter name="aOpenUILinkParams"/>
-        <body><![CDATA[
-          if (!aEvent) {
-            return false;
-          }
-
-          let source = null;
-          let type = "unknown";
-          let engine = null;
-          let target = aEvent.originalTarget;
-
-          if (aEvent instanceof KeyboardEvent) {
-            type = "key";
-            if (this.selectedButton) {
-              source = "oneoff";
-              engine = this.selectedButton.engine;
-            }
-          } else if (aEvent instanceof MouseEvent) {
-            type = "mouse";
-            if (target.classList.contains("searchbar-engine-one-off-item")) {
-              source = "oneoff";
-              engine = target.engine;
-            }
-          } else if ((aEvent instanceof XULCommandEvent) &&
-                     target.getAttribute("anonid") ==
-                       "search-one-offs-context-open-in-new-tab") {
-            source = "oneoff-context";
-            engine = this._contextEngine;
-          }
-
-          if (!source) {
-            return false;
-          }
+  /**
+   * If the given event is related to the one-offs, this method records
+   * one-off telemetry for it.  this.telemetryOrigin will be appended to the
+   * computed source, so make sure you set that first.
+   *
+   * @param aEvent
+   *        An event, like a click on a one-off button.
+   * @param aOpenUILinkWhere
+   *        The "where" passed to openUILink.
+   * @param aOpenUILinkParams
+   *        The "params" passed to openUILink.
+   * @return True if telemetry was recorded and false if not.
+   */
+  maybeRecordTelemetry(aEvent, aOpenUILinkWhere, aOpenUILinkParams) {
+    if (!aEvent) {
+      return false;
+    }
 
-          if (this.telemetryOrigin) {
-            source += "-" + this.telemetryOrigin;
-          }
-
-          let tabBackground = aOpenUILinkWhere == "tab" &&
-                              aOpenUILinkParams &&
-                              aOpenUILinkParams.inBackground;
-          let where = tabBackground ? "tab-background" : aOpenUILinkWhere;
-          BrowserSearch.recordOneoffSearchInTelemetry(engine, source, type,
-                                                      where);
-          return true;
-        ]]></body>
-      </method>
-
-      <!-- All this stuff is to make the add-engines menu button behave like an
-           actual menu.  The add-engines menu button is shown when there are
-           many engines offered by the current site. -->
-      <field name="_addEngineMenuTimeoutMs">200</field>
-      <field name="_addEngineMenuTimeout">null</field>
-      <field name="_addEngineMenuShouldBeOpen">false</field>
-
-      <method name="_resetAddEngineMenuTimeout">
-        <body><![CDATA[
-        if (this._addEngineMenuTimeout) {
-          clearTimeout(this._addEngineMenuTimeout);
-        }
-        this._addEngineMenuTimeout = setTimeout(() => {
-          delete this._addEngineMenuTimeout;
-          let button = document.getAnonymousElementByAttribute(
-            this, "anonid", "addengine-menu-button"
-          );
-          button.open = this._addEngineMenuShouldBeOpen;
-        }, this._addEngineMenuTimeoutMs);
-        ]]></body>
-      </method>
-
-    </implementation>
-
-    <handlers>
-
-      <handler event="mousedown"><![CDATA[
-        let target = event.originalTarget;
-        if (target.getAttribute("anonid") == "addengine-menu-button") {
-          return;
-        }
-        // Required to receive click events from the buttons on Linux.
-        event.preventDefault();
-      ]]></handler>
+    let source = null;
+    let type = "unknown";
+    let engine = null;
+    let target = aEvent.originalTarget;
 
-      <handler event="mousemove"><![CDATA[
-        let target = event.originalTarget;
-
-        // Handle mouseover on the add-engine menu button and its popup items.
-        if (target.getAttribute("anonid") == "addengine-menu-button" ||
-            (target.localName == "menuitem" &&
-             target.classList.contains("addengine-item"))) {
-          let menuButton = document.getAnonymousElementByAttribute(
-            this, "anonid", "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;
+    if (aEvent instanceof KeyboardEvent) {
+      type = "key";
+      if (this.selectedButton) {
+        source = "oneoff";
+        engine = this.selectedButton.engine;
+      }
+    } else if (aEvent instanceof MouseEvent) {
+      type = "mouse";
+      if (target.classList.contains("searchbar-engine-one-off-item")) {
+        source = "oneoff";
+        engine = target.engine;
+      }
+    } else if (aEvent instanceof XULCommandEvent &&
+               target.classList.contains("search-one-offs-context-open-in-new-tab")) {
+      source = "oneoff-context";
+      engine = this._contextEngine;
+    }
 
-        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);
-        }
-      ]]></handler>
-
-      <handler event="mouseout"><![CDATA[
-
-        let target = event.originalTarget;
-
-        // Handle mouseout on the add-engine menu button and its popup items.
-        if (target.getAttribute("anonid") == "addengine-menu-button" ||
-            (target.localName == "menuitem" &&
-             target.classList.contains("addengine-item"))) {
-          this._updateStateForButton(null);
-          this._addEngineMenuShouldBeOpen = false;
-          this._resetAddEngineMenuTimeout();
-          return;
-        }
-
-        if (target.localName != "button") {
-          return;
-        }
+    if (!source) {
+      return false;
+    }
 
-        // Don't update the mouseover state if the context menu is open.
-        if (this._ignoreMouseEvents)
-          return;
-
-        this._updateStateForButton(null);
-      ]]></handler>
-
-      <handler event="click"><![CDATA[
-        if (event.button == 2)
-          return; // ignore right clicks.
+    if (this.telemetryOrigin) {
+      source += "-" + this.telemetryOrigin;
+    }
 
-        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);
-      ]]></handler>
+    let tabBackground = aOpenUILinkWhere == "tab" &&
+      aOpenUILinkParams &&
+      aOpenUILinkParams.inBackground;
+    let where = tabBackground ? "tab-background" : aOpenUILinkWhere;
+    BrowserSearch.recordOneoffSearchInTelemetry(engine, source, type, where);
+    return true;
+  }
 
-      <handler event="command"><![CDATA[
-        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);
-        }
-        let anonid = target.getAttribute("anonid");
-        if (anonid == "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 (anonid == "search-one-offs-context-set-default") {
-          let currentEngine = Services.search.currentEngine;
+  _resetAddEngineMenuTimeout() {
+    if (this._addEngineMenuTimeout) {
+      clearTimeout(this._addEngineMenuTimeout);
+    }
+    this._addEngineMenuTimeout = setTimeout(() => {
+      delete this._addEngineMenuTimeout;
+      let button = this.querySelector(".addengine-menu-button");
+      button.open = this._addEngineMenuShouldBeOpen;
+    }, this._addEngineMenuTimeoutMs);
+  }
+}
 
-          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.currentEngine = this._contextEngine;
-        }
-      ]]></handler>
+MozXULElement.implementCustomInterface(MozSearchOneOffs, [Ci.nsIObserver, Ci.nsIWeakReference]);
+customElements.define("search-one-offs", MozSearchOneOffs);
 
-      <handler event="contextmenu"><![CDATA[
-        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;
-        }
-        document.getAnonymousElementByAttribute(this, "anonid", "search-one-offs-context-set-default")
-                .setAttribute("disabled", target.engine == Services.search.currentEngine);
-
-        this._contextEngine = target.engine;
-      ]]></handler>
-    </handlers>
-
-  </binding>
-
-</bindings>
+}
--- 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:vbox anonid="search-one-off-buttons" class="search-one-offs"/>
+      <xul:search-one-offs 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
@@ -575,1267 +575,9 @@
           return;
         }
         this.oneOffButtons.handleSearchCommand(event, engine);
       ]]></handler>
     </handlers>
 
   </binding>
 
-  <binding id="search-one-offs">
-    <content context="_child">
-      <xul:deck anonid="search-panel-one-offs-header"
-                selectedIndex="0"
-                class="search-panel-header search-panel-current-input">
-        <xul:label anonid="searchbar-oneoffheader-search"
-                   value="&searchWithHeader.label;"/>
-        <xul:hbox anonid="search-panel-searchforwith"
-                  class="search-panel-current-input">
-          <xul:label anonid="searchbar-oneoffheader-before"
-                     value="&searchFor.label;"/>
-          <xul:label anonid="searchbar-oneoffheader-searchtext"
-                     class="search-panel-input-value"
-                     flex="1"
-                     crop="end"/>
-          <xul:label anonid="searchbar-oneoffheader-after"
-                     flex="10000"
-                     value="&searchWith.label;"/>
-        </xul:hbox>
-        <xul:hbox anonid="search-panel-searchonengine"
-                  class="search-panel-current-input">
-          <xul:label anonid="searchbar-oneoffheader-beforeengine"
-                     value="&search.label;"/>
-          <xul:label anonid="searchbar-oneoffheader-engine"
-                     class="search-panel-input-value"
-                     flex="1"
-                     crop="end"/>
-          <xul:label anonid="searchbar-oneoffheader-afterengine"
-                     flex="10000"
-                     value="&searchAfter.label;"/>
-        </xul:hbox>
-      </xul:deck>
-      <xul:description anonid="search-panel-one-offs"
-                       role="group"
-                       class="search-panel-one-offs"
-                       xbl:inherits="compact">
-        <xul:button anonid="search-settings-compact"
-                    oncommand="showSettings();"
-                    class="searchbar-engine-one-off-item search-setting-button-compact"
-                    tooltiptext="&changeSearchSettings.tooltip;"
-                    xbl:inherits="compact"/>
-      </xul:description>
-      <xul:vbox anonid="add-engines" class="search-add-engines"/>
-      <xul:button anonid="search-settings"
-                  oncommand="showSettings();"
-                  class="search-setting-button search-panel-header"
-                  label="&changeSearchSettings.button;"
-                  xbl:inherits="compact"/>
-      <xul:menupopup anonid="search-one-offs-context-menu">
-        <xul:menuitem anonid="search-one-offs-context-open-in-new-tab"
-                      label="&searchInNewTab.label;"
-                      accesskey="&searchInNewTab.accesskey;"/>
-        <xul:menuitem anonid="search-one-offs-context-set-default"
-                      label="&searchSetAsDefault.label;"
-                      accesskey="&searchSetAsDefault.accesskey;"/>
-      </xul:menupopup>
-    </content>
-
-    <implementation implements="nsIObserver,nsIWeakReference">
-
-      <!-- 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. -->
-      <property name="buttonWidth" readonly="true" onget="return 49;"/>
-
-      <field name="_popup">null</field>
-
-      <!-- The popup that contains the one-offs.  This is required, so it should
-           never be null or undefined, except possibly before the one-offs are
-           used. -->
-      <property name="popup">
-        <getter><![CDATA[
-          return this._popup;
-        ]]></getter>
-        <setter><![CDATA[
-          let events = [
-            "popupshowing",
-            "popuphidden",
-          ];
-          if (this._popup) {
-            for (let event of events) {
-              this._popup.removeEventListener(event, this);
-            }
-          }
-          if (val) {
-            for (let event of events) {
-              val.addEventListener(event, this);
-            }
-          }
-          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;
-        ]]></setter>
-      </property>
-
-      <field name="_textbox">null</field>
-      <field name="_textboxWidth">0</field>
-
-      <!-- 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. -->
-      <property name="textbox">
-        <getter><![CDATA[
-          return this._textbox;
-        ]]></getter>
-        <setter><![CDATA[
-          if (this._textbox) {
-            this._textbox.removeEventListener("input", this);
-          }
-          if (val) {
-            val.addEventListener("input", this);
-          }
-          return this._textbox = val;
-        ]]></setter>
-      </property>
-
-      <!-- Set this to a string that identifies your one-offs consumer.  It'll
-           be appended to telemetry recorded with maybeRecordTelemetry(). -->
-      <field name="telemetryOrigin">""</field>
-
-      <field name="_query">""</field>
-
-      <!-- The query string currently shown in the one-offs.  If the textbox
-           property is non-null, then this is automatically updated on
-           input. -->
-      <property name="query">
-        <getter><![CDATA[
-          return this._query;
-        ]]></getter>
-        <setter><![CDATA[
-          this._query = val;
-          if (this.popup && this.popup.popupOpen) {
-            this._updateAfterQueryChanged();
-          }
-          return val;
-        ]]></setter>
-      </property>
-
-      <field name="_selectedButton">null</field>
-
-      <!-- The selected one-off, a xul:button, including the add-engine button
-           and the search-settings button.  Null if no one-off is selected. -->
-      <property name="selectedButton">
-        <getter><![CDATA[
-          return this._selectedButton;
-        ]]></getter>
-        <setter><![CDATA[
-          if (val && val.classList.contains("dummy")) {
-            // Never select dummy buttons.
-            val = null;
-          }
-          let previousButton = this._selectedButton;
-          if (previousButton) {
-            previousButton.removeAttribute("selected");
-          }
-          if (val) {
-            val.setAttribute("selected", "true");
-          }
-          this._selectedButton = val;
-          this._updateStateForButton(null);
-          if (val && !val.engine) {
-            // If the button doesn't have an engine, then clear the popup's
-            // selection to indicate that pressing Return while the button is
-            // selected will do the button's command, not search.
-            this.popup.selectedIndex = -1;
-          }
-          let event = new CustomEvent("SelectedOneOffButtonChanged", {
-            previousSelectedButton: previousButton,
-          });
-          this.dispatchEvent(event);
-          return val;
-        ]]></setter>
-      </property>
-
-      <!-- The index of the selected one-off, including the add-engine button
-           and the search-settings button.  -1 if no one-off is selected. -->
-      <property name="selectedButtonIndex">
-        <getter><![CDATA[
-          let buttons = this.getSelectableButtons(true);
-          for (let i = 0; i < buttons.length; i++) {
-            if (buttons[i] == this._selectedButton) {
-              return i;
-            }
-          }
-          return -1;
-        ]]></getter>
-        <setter><![CDATA[
-          let buttons = this.getSelectableButtons(true);
-          this.selectedButton = buttons[val];
-          return val;
-        ]]></setter>
-      </property>
-
-      <property name="compact" readonly="true">
-        <getter><![CDATA[
-          return this.getAttribute("compact") == "true";
-        ]]></getter>
-      </property>
-
-      <field name="buttons" readonly="true">
-        document.getAnonymousElementByAttribute(this, "anonid", "search-panel-one-offs");
-      </field>
-      <field name="header" readonly="true">
-        document.getAnonymousElementByAttribute(this, "anonid", "search-panel-one-offs-header");
-      </field>
-      <field name="addEngines" readonly="true">
-        document.getAnonymousElementByAttribute(this, "anonid", "add-engines");
-      </field>
-      <field name="settingsButton" readonly="true">
-        document.getAnonymousElementByAttribute(this, "anonid", "search-settings");
-      </field>
-      <field name="settingsButtonCompact" readonly="true">
-        document.getAnonymousElementByAttribute(this, "anonid", "search-settings-compact");
-      </field>
-
-      <field name="_bundle">null</field>
-
-      <property name="bundle" readonly="true">
-        <getter><![CDATA[
-          if (!this._bundle) {
-            const kBundleURI = "chrome://browser/locale/search.properties";
-            this._bundle = Services.strings.createBundle(kBundleURI);
-          }
-          return this._bundle;
-        ]]></getter>
-      </property>
-
-      <!-- When a context menu is opened on a one-off button, this is set to the
-           engine of that button for use with the context menu actions. -->
-      <field name="_contextEngine">null</field>
-
-      <constructor><![CDATA[
-        // Force the <deck> Custom Element to be constructed. This can be removed
-        // once Bug 1470242 makes this happen behind the scenes.
-        customElements.upgrade(this.header);
-
-        // Prevent popup events from the context menu from reaching the autocomplete
-        // binding (or other listeners).
-        let menu = document.getAnonymousElementByAttribute(this, "anonid", "search-one-offs-context-menu");
-        let listener = aEvent => aEvent.stopPropagation();
-        menu.addEventListener("popupshowing", listener);
-        menu.addEventListener("popuphiding", listener);
-        menu.addEventListener("popupshown", aEvent => {
-          this._ignoreMouseEvents = true;
-          aEvent.stopPropagation();
-        });
-        menu.addEventListener("popuphidden", aEvent => {
-          this._ignoreMouseEvents = false;
-          aEvent.stopPropagation();
-        });
-
-        // Add weak referenced observers to invalidate our cached list of engines.
-        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);
-      ]]></constructor>
-
-      <!-- This handles events outside the one-off buttons, like on the popup
-           and textbox. -->
-      <method name="handleEvent">
-        <parameter name="event"/>
-        <body><![CDATA[
-          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;
-          }
-        ]]></body>
-      </method>
-
-      <method name="observe">
-        <parameter name="aEngine"/>
-        <parameter name="aTopic"/>
-        <parameter name="aData"/>
-        <body><![CDATA[
-          // Make sure the engine list is refetched next time it's needed.
-          this._engines = null;
-        ]]></body>
-      </method>
-
-      <method name="showSettings">
-        <body><![CDATA[
-          openPreferences("paneSearch", {origin: "contentSearch"});
-
-          // If the preference tab was already selected, the panel doesn't
-          // close itself automatically.
-          this.popup.hidePopup();
-        ]]></body>
-      </method>
-
-      <!-- Updates the parts of the UI that show the query string. -->
-      <method name="_updateAfterQueryChanged">
-        <body><![CDATA[
-          let headerSearchText =
-            document.getAnonymousElementByAttribute(this, "anonid",
-                                                    "searchbar-oneoffheader-searchtext");
-          headerSearchText.setAttribute("value", this.query);
-          let groupText;
-          let isOneOffSelected =
-            this.selectedButton &&
-            this.selectedButton.classList.contains("searchbar-engine-one-off-item");
-          // Typing de-selects the settings or opensearch buttons at the bottom
-          // of the search panel, as typing shows the user intends to search.
-          if (this.selectedButton && !isOneOffSelected)
-            this.selectedButton = null;
-          if (this.query) {
-            groupText = headerSearchText.previousElementSibling.value +
-                        '"' + headerSearchText.value + '"' +
-                        headerSearchText.nextElementSibling.value;
-            if (!isOneOffSelected)
-              this.header.selectedIndex = 1;
-          } else {
-            let noSearchHeader =
-              document.getAnonymousElementByAttribute(this, "anonid",
-                                                      "searchbar-oneoffheader-search");
-            groupText = noSearchHeader.value;
-            if (!isOneOffSelected)
-              this.header.selectedIndex = 0;
-          }
-          this.buttons.setAttribute("aria-label", groupText);
-        ]]></body>
-      </method>
-
-      <field name="_engines">null</field>
-      <property name="engines" readonly="true">
-        <getter><![CDATA[
-          if (this._engines)
-            return this._engines;
-          let currentEngineNameToIgnore;
-          if (!this.getAttribute("includecurrentengine"))
-            currentEngineNameToIgnore = Services.search.currentEngine.name;
-
-          let pref = Services.prefs.getStringPref("browser.search.hiddenOneOffs");
-          let hiddenList = pref ? pref.split(",") : [];
-
-          this._engines = Services.search.getVisibleEngines().filter(e => {
-            let name = e.name;
-            return (!currentEngineNameToIgnore ||
-                    name != currentEngineNameToIgnore) &&
-                   !hiddenList.includes(name);
-          });
-
-          return this._engines;
-        ]]></getter>
-      </property>
-
-      <!-- Builds all the UI. -->
-      <method name="_rebuild">
-        <body><![CDATA[
-          // Update the 'Search for <keywords> with:" header.
-          this._updateAfterQueryChanged();
-
-          // Handle opensearch items. This needs to be done before building the
-          // list of one off providers, as that code will return early if all the
-          // alternative engines are hidden.
-          // Skip this in compact mode, ie. for the urlbar.
-          if (!this.compact)
-            this._rebuildAddEngineList();
-
-          // Check if the one-off buttons really need to be rebuilt.
-          if (this._textbox) {
-            // We can't get a reliable value for the popup width without flushing,
-            // but the popup width won't change if the textbox width doesn't.
-            let DOMUtils = window.windowUtils;
-            let textboxWidth =
-              DOMUtils.getBoundsWithoutFlushing(this._textbox).width;
-            // We can return early if neither the list of engines nor the panel
-            // width has changed.
-            if (this._engines && this._textboxWidth == textboxWidth) {
-              return;
-            }
-            this._textboxWidth = textboxWidth;
-          }
-
-          // Finally, build the list of one-off buttons.
-          while (this.buttons.firstElementChild != this.settingsButtonCompact)
-            this.buttons.firstElementChild.remove();
-          // Remove the trailing empty text node introduced by the binding's
-          // content markup above.
-          if (this.settingsButtonCompact.nextElementSibling)
-            this.settingsButtonCompact.nextElementSibling.remove();
-
-          let engines = this.engines;
-          let oneOffCount = engines.length;
-          let collapsed = !oneOffCount ||
-                          (oneOffCount == 1 && engines[0].name == Services.search.currentEngine.name);
-
-          // header is a xul:deck so collapsed doesn't work on it, see bug 589569.
-          this.header.hidden = this.buttons.collapsed = collapsed;
-
-          if (collapsed)
-            return;
-
-          let panelWidth = parseInt(this.popup.clientWidth);
-
-          // There's one weird thing to guard against: when layout pixels
-          // aren't an integral multiple of device pixels, the last button
-          // of each row sometimes gets pushed to the next row, depending on the
-          // panel and button widths.
-          // This is likely because the clientWidth getter rounds the value, but
-          // the panel's border width is not an integer.
-          // As a workaround, decrement the width if the scale is not an integer.
-          let scale = window.windowUtils.screenPixelsPerCSSPixel;
-          if (Math.floor(scale) != scale) {
-            --panelWidth;
-          }
-
-          // The + 1 is because the last button doesn't have a right border.
-          let enginesPerRow = Math.floor((panelWidth + 1) / this.buttonWidth);
-          let buttonWidth = Math.floor(panelWidth / enginesPerRow);
-          // There will be an emtpy area of:
-          //   panelWidth - enginesPerRow * buttonWidth  px
-          // at the end of each row.
-
-          // If the <description> tag with the list of search engines doesn't have
-          // a fixed height, the panel will be sized incorrectly, causing the bottom
-          // of the suggestion <tree> to be hidden.
-          if (this.compact)
-            ++oneOffCount;
-          let rowCount = Math.ceil(oneOffCount / enginesPerRow);
-          let height = rowCount * 33; // 32px per row, 1px border.
-          this.buttons.setAttribute("height", height + "px");
-
-          // Ensure we can refer to the settings buttons by ID:
-          let origin = this.telemetryOrigin;
-          this.settingsButton.id = origin + "-anon-search-settings";
-          this.settingsButtonCompact.id = origin + "-anon-search-settings-compact";
-
-          let dummyItems = enginesPerRow - (oneOffCount % enginesPerRow || enginesPerRow);
-          for (let i = 0; i < engines.length; ++i) {
-            let engine = engines[i];
-            let button = document.createXULElement("button");
-            button.id = this._buttonIDForEngine(engine);
-            let uri = "chrome://browser/skin/search-engine-placeholder.png";
-            if (engine.iconURI) {
-              uri = engine.iconURI.spec;
-            }
-            button.setAttribute("image", uri);
-            button.setAttribute("class", "searchbar-engine-one-off-item");
-            button.setAttribute("tooltiptext", engine.name);
-            button.setAttribute("width", buttonWidth);
-            button.engine = engine;
-
-            if ((i + 1) % enginesPerRow == 0)
-              button.classList.add("last-of-row");
-
-            if (i + 1 == engines.length)
-              button.classList.add("last-engine");
-
-            if (i >= oneOffCount + dummyItems - enginesPerRow)
-              button.classList.add("last-row");
-
-            this.buttons.insertBefore(button, this.settingsButtonCompact);
-          }
-
-          let hasDummyItems = !!dummyItems;
-          while (dummyItems) {
-            let button = document.createXULElement("button");
-            button.setAttribute("class", "searchbar-engine-one-off-item dummy last-row");
-            button.setAttribute("width", buttonWidth);
-
-            if (!--dummyItems)
-              button.classList.add("last-of-row");
-
-            this.buttons.insertBefore(button, this.settingsButtonCompact);
-          }
-
-          if (this.compact) {
-            this.settingsButtonCompact.setAttribute("width", buttonWidth);
-            if (rowCount == 1 && hasDummyItems) {
-              // When there's only one row, make the compact settings button
-              // hug the right edge of the panel.  It may not due to the panel's
-              // width not being an integral multiple of the button width.  (See
-              // the "There will be an emtpy area" comment above.)  Increase the
-              // width of the last dummy item by the remainder.
-              let remainder = panelWidth - (enginesPerRow * buttonWidth);
-              let width = remainder + buttonWidth;
-              let lastDummyItem = this.settingsButtonCompact.previousElementSibling;
-              lastDummyItem.setAttribute("width", width);
-            }
-          }
-        ]]></body>
-      </method>
-
-      <!-- If a page offers more than this number of engines, the add-engines
-           menu button is shown, instead of showing the engines directly in the
-           popup. -->
-      <field name="_addEngineMenuThreshold">5</field>
-
-      <method name="_rebuildAddEngineList">
-        <body><![CDATA[
-        let list = this.addEngines;
-        while (list.firstChild) {
-          list.firstChild.remove();
-        }
-
-        // Add a button for each engine that the page in the selected browser
-        // offers, except when there are too many offered engines.
-        // The popup isn't designed to handle too many (by scrolling for
-        // example), so a page could break the popup by offering too many.
-        // Instead, add a single menu button with a submenu of all the engines.
-
-        if (!gBrowser.selectedBrowser.engines) {
-          return;
-        }
-
-        let engines = gBrowser.selectedBrowser.engines;
-        let tooManyEngines = engines.length > this._addEngineMenuThreshold;
-
-        if (tooManyEngines) {
-          // Make the top-level menu button.
-          let button = document.createXULElement("toolbarbutton");
-          list.appendChild(button);
-          button.classList.add("addengine-item", "badged-button");
-          button.setAttribute("anonid", "addengine-menu-button");
-          button.setAttribute("type", "menu");
-          button.setAttribute("label",
-            this.bundle.GetStringFromName("cmd_addFoundEngineMenu"));
-          button.setAttribute("crop", "end");
-          button.setAttribute("pack", "start");
-
-          // Set the menu button's image to the image of the first engine.  The
-          // offered engines may have differing images, so there's no perfect
-          // choice here.
-          let engine = engines[0];
-          if (engine.icon) {
-            button.setAttribute("image", engine.icon);
-          }
-
-          // Now make the button's child menupopup.
-          list = document.createXULElement("menupopup");
-          button.appendChild(list);
-          list.setAttribute("anonid", "addengine-menu");
-          list.setAttribute("position", "topright topleft");
-
-          // Events from child menupopups bubble up to the autocomplete binding,
-          // which breaks it, so prevent these events from propagating.
-          let suppressEventTypes = [
-            "popupshowing",
-            "popuphiding",
-            "popupshown",
-            "popuphidden",
-          ];
-          for (let type of suppressEventTypes) {
-            list.addEventListener(type, event => {
-              event.stopPropagation();
-            });
-          }
-        }
-
-        // Finally, add the engines to the list.  If there aren't too many
-        // engines, the list is the add-engines vbox.  Otherwise it's the
-        // menupopup created earlier.  In the latter case, create menuitem
-        // elements instead of buttons, because buttons don't get keyboard
-        // handling for free inside menupopups.
-        let eltType = tooManyEngines ? "menuitem" : "toolbarbutton";
-        for (let engine of engines) {
-          let button = document.createXULElement(eltType);
-          button.classList.add("addengine-item");
-          if (!tooManyEngines) {
-            button.classList.add("badged-button");
-          }
-          button.id = this.telemetryOrigin + "-add-engine-" +
-                      this._fixUpEngineNameForID(engine.title);
-          let label = this.bundle.formatStringFromName("cmd_addFoundEngine",
-                                                       [engine.title], 1);
-          button.setAttribute("label", label);
-          button.setAttribute("crop", "end");
-          button.setAttribute("tooltiptext", engine.title + "\n" + engine.uri);
-          button.setAttribute("uri", engine.uri);
-          button.setAttribute("title", engine.title);
-          if (engine.icon) {
-            button.setAttribute("image", engine.icon);
-          }
-          if (tooManyEngines) {
-            button.classList.add("menuitem-iconic");
-          } else {
-            button.setAttribute("pack", "start");
-          }
-          list.appendChild(button);
-        }
-        ]]></body>
-      </method>
-
-      <method name="_buttonIDForEngine">
-        <parameter name="engine"/>
-        <body><![CDATA[
-          return this.telemetryOrigin + "-engine-one-off-item-" +
-                 this._fixUpEngineNameForID(engine.name);
-        ]]></body>
-      </method>
-
-      <method name="_fixUpEngineNameForID">
-        <parameter name="name"/>
-        <body><![CDATA[
-          return name.replace(/ /g, "-");
-        ]]></body>
-      </method>
-
-      <method name="_buttonForEngine">
-        <parameter name="engine"/>
-        <body><![CDATA[
-          return document.getElementById(this._buttonIDForEngine(engine));
-        ]]></body>
-      </method>
-
-      <!--
-        Updates the popup and textbox for the currently selected or moused-over
-        button.
-
-        @param mousedOverButton
-               The currently moused-over button, or null if there isn't one.
-      -->
-      <method name="_updateStateForButton">
-        <parameter name="mousedOverButton"/>
-        <body><![CDATA[
-          let button = mousedOverButton;
-
-          // Ignore dummy buttons.
-          if (button && button.classList.contains("dummy")) {
-            button = null;
-          }
-
-          // If there's no moused-over button, then the one-offs should reflect
-          // the selected button, if any.
-          button = button || this.selectedButton;
-
-          if (!button) {
-            this.header.selectedIndex = this.query ? 1 : 0;
-            if (this.textbox) {
-              this.textbox.removeAttribute("aria-activedescendant");
-            }
-            return;
-          }
-
-          if (button.classList.contains("searchbar-engine-one-off-item") &&
-              button.engine) {
-            let headerEngineText =
-              document.getAnonymousElementByAttribute(this, "anonid",
-                                                      "searchbar-oneoffheader-engine");
-            this.header.selectedIndex = 2;
-            headerEngineText.value = button.engine.name;
-          } else {
-            this.header.selectedIndex = this.query ? 1 : 0;
-          }
-          if (this.textbox) {
-            this.textbox.setAttribute("aria-activedescendant", button.id);
-          }
-        ]]></body>
-      </method>
-
-      <method name="getSelectableButtons">
-        <parameter name="aIncludeNonEngineButtons"/>
-        <body><![CDATA[
-          let buttons = [];
-          for (let oneOff = this.buttons.firstElementChild; oneOff; oneOff = oneOff.nextElementSibling) {
-            // oneOff may be a text node since the list xul:description contains
-            // whitespace and the compact settings button.  See the markup
-            // above.  _rebuild removes text nodes, but it may not have been
-            // called yet (because e.g. the popup hasn't been opened yet).
-            if (oneOff.nodeType == Node.ELEMENT_NODE) {
-              if (oneOff.classList.contains("dummy") ||
-                  oneOff.classList.contains("search-setting-button-compact"))
-                break;
-              buttons.push(oneOff);
-            }
-          }
-
-          if (aIncludeNonEngineButtons) {
-            for (let addEngine = this.addEngines.firstElementChild; addEngine; addEngine = addEngine.nextElementSibling) {
-              buttons.push(addEngine);
-            }
-            buttons.push(this.compact ? this.settingsButtonCompact : this.settingsButton);
-          }
-
-          return buttons;
-        ]]></body>
-      </method>
-
-      <method name="handleSearchCommand">
-        <parameter name="aEvent"/>
-        <parameter name="aEngine"/>
-        <parameter name="aForceNewTab"/>
-        <body><![CDATA[
-          let where = "current";
-          let params;
-
-          // Open ctrl/cmd clicks on one-off buttons in a new background tab.
-          if (aForceNewTab) {
-            where = "tab";
-            if (Services.prefs.getBoolPref("browser.tabs.loadInBackground")) {
-              params = {
-                inBackground: true,
-              };
-            }
-          } else {
-            var newTabPref = Services.prefs.getBoolPref("browser.search.openintab");
-            if (((aEvent instanceof KeyboardEvent && aEvent.altKey) ^ newTabPref) &&
-                !isTabEmpty(gBrowser.selectedTab)) {
-              where = "tab";
-            }
-            if ((aEvent instanceof MouseEvent) &&
-                (aEvent.button == 1 || aEvent.getModifierState("Accel"))) {
-              where = "tab";
-              params = {
-                inBackground: true,
-              };
-            }
-          }
-
-          this.popup.handleOneOffSearch(aEvent, aEngine, where, params);
-        ]]></body>
-      </method>
-
-      <!--
-        Increments or decrements the index of the currently selected one-off.
-
-        @param aForward
-               If true, the index is incremented, and if false, the index is
-               decremented.
-        @param aIncludeNonEngineButtons
-               If true, non-dummy buttons that do not have engines are included.
-               These buttons include the OpenSearch and settings buttons.  For
-               example, if the currently selected button is an engine button,
-               the next button is the settings button, and you pass true for
-               aForward, then passing true for this value would cause the
-               settings to be selected.  Passing false for this value would
-               cause the selection to clear or wrap around, depending on what
-               value you passed for the aWrapAround parameter.
-        @param aWrapAround
-               If true, the selection wraps around between the first and last
-               buttons.
-        @return True if the selection can continue to advance after this method
-                returns and false if not.
-      -->
-      <method name="advanceSelection">
-        <parameter name="aForward"/>
-        <parameter name="aIncludeNonEngineButtons"/>
-        <parameter name="aWrapAround"/>
-        <body><![CDATA[
-          let buttons = this.getSelectableButtons(aIncludeNonEngineButtons);
-          let index;
-          if (this.selectedButton) {
-            let inc = aForward ? 1 : -1;
-            let oldIndex = buttons.indexOf(this.selectedButton);
-            index = ((oldIndex + inc) + buttons.length) % buttons.length;
-            if (!aWrapAround &&
-                ((aForward && index <= oldIndex) ||
-                 (!aForward && oldIndex <= index))) {
-              // The index has wrapped around, but wrapping around isn't
-              // allowed.
-              index = -1;
-            }
-          } else {
-            index = aForward ? 0 : buttons.length - 1;
-          }
-          this.selectedButton = index < 0 ? null : buttons[index];
-        ]]></body>
-      </method>
-
-      <!--
-        This handles key presses specific to the one-off buttons like Tab and
-        Alt+Up/Down, and Up/Down keys within the buttons.  Since one-off buttons
-        are always used in conjunction with a list of some sort (in this.popup),
-        it also handles Up/Down keys that cross the boundaries between list
-        items and the one-off buttons.
-
-        If this method handles the key press, then event.defaultPrevented will
-        be true when it returns.
-
-        @param event
-               The key event.
-        @param numListItems
-               The number of items in the list.  The reason that this is a
-               parameter at all is that the list may contain items at the end
-               that should be ignored, depending on the consumer.  That's true
-               for the urlbar for example.
-        @param allowEmptySelection
-               Pass true if it's OK that neither the list nor the one-off
-               buttons contains a selection.  Pass false if either the list or
-               the one-off buttons (or both) should always contain a selection.
-        @param textboxUserValue
-               When the last list item is selected and the user presses Down,
-               the first one-off becomes selected and the textbox value is
-               restored to the value that the user typed.  Pass that value here.
-               However, if you pass true for allowEmptySelection, you don't need
-               to pass anything for this parameter.  (Pass undefined or null.)
-      -->
-      <method name="handleKeyPress">
-        <parameter name="event"/>
-        <parameter name="numListItems"/>
-        <parameter name="allowEmptySelection"/>
-        <parameter name="textboxUserValue"/>
-        <body><![CDATA[
-          if (!this.popup) {
-            return;
-          }
-          let handled = this._handleKeyPress(event, numListItems,
-                                             allowEmptySelection,
-                                             textboxUserValue);
-          if (handled) {
-            event.preventDefault();
-            event.stopPropagation();
-          }
-        ]]></body>
-      </method>
-
-      <method name="_handleKeyPress">
-        <parameter name="event"/>
-        <parameter name="numListItems"/>
-        <parameter name="allowEmptySelection"/>
-        <parameter name="textboxUserValue"/>
-        <body><![CDATA[
-          if (this.compact && this.buttons.collapsed)
-            return false;
-          if (event.keyCode == KeyEvent.DOM_VK_RIGHT &&
-              this.selectedButton &&
-              this.selectedButton.getAttribute("anonid") ==
-                "addengine-menu-button") {
-            // If the add-engine overflow menu item is selected and the user
-            // presses the right arrow key, open the submenu.  Unfortunately
-            // handling the left arrow key -- to close the popup -- isn't
-            // straightforward.  Once the popup is open, it consumes all key
-            // events.  Setting ignorekeys=handled on it doesn't help, since the
-            // popup handles all arrow keys.  Setting ignorekeys=true on it does
-            // mean that the popup no longer consumes the left arrow key, but
-            // then it no longer handles up/down keys to select items in the
-            // popup.
-            this.selectedButton.open = true;
-            return true;
-          }
-
-          // Handle the Tab key, but only if non-Shift modifiers aren't also
-          // pressed to avoid clobbering other shortcuts (like the Alt+Tab
-          // browser tab switcher).  The reason this uses getModifierState() and
-          // checks for "AltGraph" is that when you press Shift-Alt-Tab,
-          // event.altKey is actually false for some reason, at least on macOS.
-          // getModifierState("Alt") is also false, but "AltGraph" is true.
-          if (event.keyCode == KeyEvent.DOM_VK_TAB &&
-              !event.getModifierState("Alt") &&
-              !event.getModifierState("AltGraph") &&
-              !event.getModifierState("Control") &&
-              !event.getModifierState("Meta")) {
-            if (this.getAttribute("disabletab") == "true" ||
-                (event.shiftKey &&
-                  this.selectedButtonIndex <= 0) ||
-                (!event.shiftKey &&
-                 this.selectedButtonIndex ==
-                   this.getSelectableButtons(true).length - 1)) {
-              this.selectedButton = null;
-              return false;
-            }
-            this.popup.selectedIndex = -1;
-            this.advanceSelection(!event.shiftKey, true, false);
-            return !!this.selectedButton;
-          }
-
-          if (event.keyCode == KeyboardEvent.DOM_VK_UP) {
-            if (event.altKey) {
-              // Keep the currently selected result in the list (if any) as a
-              // secondary "alt" selection and move the selection up within the
-              // buttons.
-              this.advanceSelection(false, false, false);
-              return true;
-            }
-            if (numListItems == 0) {
-              this.advanceSelection(false, true, false);
-              return true;
-            }
-            if (this.popup.selectedIndex > 0) {
-              // Moving up within the list.  The autocomplete controller should
-              // handle this case.  A button may be selected, so null it.
-              this.selectedButton = null;
-              return false;
-            }
-            if (this.popup.selectedIndex == 0) {
-              // Moving up from the top of the list.
-              if (allowEmptySelection) {
-                // Let the autocomplete controller remove selection in the list
-                // and revert the typed text in the textbox.
-                return false;
-              }
-              // Wrap selection around to the last button.
-              if (this.textbox && typeof(textboxUserValue) == "string") {
-                this.textbox.value = textboxUserValue;
-              }
-              this.advanceSelection(false, true, true);
-              return true;
-            }
-            if (!this.selectedButton) {
-              // Moving up from no selection in the list or the buttons, back
-              // down to the last button.
-              this.advanceSelection(false, true, true);
-              return true;
-            }
-            if (this.selectedButtonIndex == 0) {
-              // Moving up from the buttons to the bottom of the list.
-              this.selectedButton = null;
-              return false;
-            }
-            // Moving up/left within the buttons.
-            this.advanceSelection(false, true, false);
-            return true;
-          }
-
-          if (event.keyCode == KeyboardEvent.DOM_VK_DOWN) {
-            if (event.altKey) {
-              // Keep the currently selected result in the list (if any) as a
-              // secondary "alt" selection and move the selection down within
-              // the buttons.
-              this.advanceSelection(true, false, false);
-              return true;
-            }
-            if (numListItems == 0) {
-              this.advanceSelection(true, true, false);
-              return true;
-            }
-            if (this.popup.selectedIndex >= 0 &&
-                this.popup.selectedIndex < numListItems - 1) {
-              // Moving down within the list.  The autocomplete controller
-              // should handle this case.  A button may be selected, so null it.
-              this.selectedButton = null;
-              return false;
-            }
-            if (this.popup.selectedIndex == numListItems - 1) {
-              // Moving down from the last item in the list to the buttons.
-              this.selectedButtonIndex = 0;
-              if (allowEmptySelection) {
-                // Let the autocomplete controller remove selection in the list
-                // and revert the typed text in the textbox.
-                return false;
-              }
-              if (this.textbox && typeof(textboxUserValue) == "string") {
-                this.textbox.value = textboxUserValue;
-              }
-              this.popup.selectedIndex = -1;
-              return true;
-            }
-            if (this.selectedButton) {
-              let buttons = this.getSelectableButtons(true);
-              if (this.selectedButtonIndex == buttons.length - 1) {
-                // Moving down from the buttons back up to the top of the list.
-                this.selectedButton = null;
-                if (allowEmptySelection) {
-                  // Prevent the selection from wrapping around to the top of
-                  // the list by returning true, since the list currently has no
-                  // selection.  Nothing should be selected after handling this
-                  // Down key.
-                  return true;
-                }
-                return false;
-              }
-              // Moving down/right within the buttons.
-              this.advanceSelection(true, true, false);
-              return true;
-            }
-            return false;
-          }
-
-          if (event.keyCode == KeyboardEvent.DOM_VK_LEFT) {
-            if (this.selectedButton &&
-                (this.compact || this.selectedButton.engine)) {
-              // Moving left within the buttons.
-              this.advanceSelection(false, this.compact, true);
-              return true;
-            }
-            return false;
-          }
-
-          if (event.keyCode == KeyboardEvent.DOM_VK_RIGHT) {
-            if (this.selectedButton &&
-                (this.compact || this.selectedButton.engine)) {
-              // Moving right within the buttons.
-              this.advanceSelection(true, this.compact, true);
-              return true;
-            }
-            return false;
-          }
-
-          return false;
-        ]]></body>
-      </method>
-
-      <!--
-        If the given event is related to the one-offs, this method records
-        one-off telemetry for it.  this.telemetryOrigin will be appended to the
-        computed source, so make sure you set that first.
-
-        @param aEvent
-               An event, like a click on a one-off button.
-        @param aOpenUILinkWhere
-               The "where" passed to openUILink.
-        @param aOpenUILinkParams
-               The "params" passed to openUILink.
-        @return True if telemetry was recorded and false if not.
-      -->
-      <method name="maybeRecordTelemetry">
-        <parameter name="aEvent"/>
-        <parameter name="aOpenUILinkWhere"/>
-        <parameter name="aOpenUILinkParams"/>
-        <body><![CDATA[
-          if (!aEvent) {
-            return false;
-          }
-
-          let source = null;
-          let type = "unknown";
-          let engine = null;
-          let target = aEvent.originalTarget;
-
-          if (aEvent instanceof KeyboardEvent) {
-            type = "key";
-            if (this.selectedButton) {
-              source = "oneoff";
-              engine = this.selectedButton.engine;
-            }
-          } else if (aEvent instanceof MouseEvent) {
-            type = "mouse";
-            if (target.classList.contains("searchbar-engine-one-off-item")) {
-              source = "oneoff";
-              engine = target.engine;
-            }
-          } else if ((aEvent instanceof XULCommandEvent) &&
-                     target.getAttribute("anonid") ==
-                       "search-one-offs-context-open-in-new-tab") {
-            source = "oneoff-context";
-            engine = this._contextEngine;
-          }
-
-          if (!source) {
-            return false;
-          }
-
-          if (this.telemetryOrigin) {
-            source += "-" + this.telemetryOrigin;
-          }
-
-          let tabBackground = aOpenUILinkWhere == "tab" &&
-                              aOpenUILinkParams &&
-                              aOpenUILinkParams.inBackground;
-          let where = tabBackground ? "tab-background" : aOpenUILinkWhere;
-          BrowserSearch.recordOneoffSearchInTelemetry(engine, source, type,
-                                                      where);
-          return true;
-        ]]></body>
-      </method>
-
-      <!-- All this stuff is to make the add-engines menu button behave like an
-           actual menu.  The add-engines menu button is shown when there are
-           many engines offered by the current site. -->
-      <field name="_addEngineMenuTimeoutMs">200</field>
-      <field name="_addEngineMenuTimeout">null</field>
-      <field name="_addEngineMenuShouldBeOpen">false</field>
-
-      <method name="_resetAddEngineMenuTimeout">
-        <body><![CDATA[
-        if (this._addEngineMenuTimeout) {
-          clearTimeout(this._addEngineMenuTimeout);
-        }
-        this._addEngineMenuTimeout = setTimeout(() => {
-          delete this._addEngineMenuTimeout;
-          let button = document.getAnonymousElementByAttribute(
-            this, "anonid", "addengine-menu-button"
-          );
-          button.open = this._addEngineMenuShouldBeOpen;
-        }, this._addEngineMenuTimeoutMs);
-        ]]></body>
-      </method>
-
-    </implementation>
-
-    <handlers>
-
-      <handler event="mousedown"><![CDATA[
-        let target = event.originalTarget;
-        if (target.getAttribute("anonid") == "addengine-menu-button") {
-          return;
-        }
-        // Required to receive click events from the buttons on Linux.
-        event.preventDefault();
-      ]]></handler>
-
-      <handler event="mousemove"><![CDATA[
-        let target = event.originalTarget;
-
-        // Handle mouseover on the add-engine menu button and its popup items.
-        if (target.getAttribute("anonid") == "addengine-menu-button" ||
-            (target.localName == "menuitem" &&
-             target.classList.contains("addengine-item"))) {
-          let menuButton = document.getAnonymousElementByAttribute(
-            this, "anonid", "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);
-        }
-      ]]></handler>
-
-      <handler event="mouseout"><![CDATA[
-
-        let target = event.originalTarget;
-
-        // Handle mouseout on the add-engine menu button and its popup items.
-        if (target.getAttribute("anonid") == "addengine-menu-button" ||
-            (target.localName == "menuitem" &&
-             target.classList.contains("addengine-item"))) {
-          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);
-      ]]></handler>
-
-      <handler event="click"><![CDATA[
-        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);
-      ]]></handler>
-
-      <handler event="command"><![CDATA[
-        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);
-        }
-        let anonid = target.getAttribute("anonid");
-        if (anonid == "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 (anonid == "search-one-offs-context-set-default") {
-          let currentEngine = Services.search.currentEngine;
-
-          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.currentEngine = this._contextEngine;
-        }
-      ]]></handler>
-
-      <handler event="contextmenu"><![CDATA[
-        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;
-        }
-        document.getAnonymousElementByAttribute(this, "anonid", "search-one-offs-context-set-default")
-                .setAttribute("disabled", target.engine == Services.search.currentEngine);
-
-        this._contextEngine = target.engine;
-      ]]></handler>
-    </handlers>
-
-  </binding>
-
 </bindings>
--- a/browser/components/search/jar.mn
+++ b/browser/components/search/jar.mn
@@ -1,13 +1,14 @@
 # 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/.
 
 browser.jar:
         content/browser/search/search.xml                           (content/search.xml)
         content/browser/search/searchbar.js                         (content/searchbar.js)
+        content/browser/search/search-one-offs.js                   (content/search-one-offs.js)
         content/browser/search/searchReset.xhtml                    (content/searchReset.xhtml)
         content/browser/search/searchReset.js                       (content/searchReset.js)
 
         searchplugins/                                              (searchplugins/**)
 
 % resource search-plugins %searchplugins/
--- a/browser/components/search/test/browser_oneOffContextMenu.js
+++ b/browser/components/search/test/browser_oneOffContextMenu.js
@@ -1,26 +1,20 @@
 "use strict";
 
 const TEST_ENGINE_NAME = "Foo";
 const TEST_ENGINE_BASENAME = "testEngine.xml";
 
 const searchPopup = document.getElementById("PopupSearchAutoComplete");
-const oneOffBinding = document.getAnonymousElementByAttribute(
+const oneOffElement = document.getAnonymousElementByAttribute(
   searchPopup, "anonid", "search-one-off-buttons"
 );
-const contextMenu = document.getAnonymousElementByAttribute(
-  oneOffBinding, "anonid", "search-one-offs-context-menu"
-);
-const oneOffButtons = document.getAnonymousElementByAttribute(
-  oneOffBinding, "anonid", "search-panel-one-offs"
-);
-const searchInNewTabMenuItem = document.getAnonymousElementByAttribute(
-  oneOffBinding, "anonid", "search-one-offs-context-open-in-new-tab"
-);
+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");
 
 let searchbar;
 let searchIcon;
 
 add_task(async function init() {
   searchbar = await gCUITestUtils.addSearchBar();
   registerCleanupFunction(() => {
     gCUITestUtils.removeSearchBar();
--- a/browser/components/search/test/browser_oneOffContextMenu_setDefault.js
+++ b/browser/components/search/test/browser_oneOffContextMenu_setDefault.js
@@ -4,20 +4,20 @@ 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 searchOneOffBinding = document.getAnonymousElementByAttribute(
+const searchOneOffElement = document.getAnonymousElementByAttribute(
   searchPopup, "anonid", "search-one-off-buttons"
 );
-const urlBarOneOffBinding = document.getAnonymousElementByAttribute(
+const urlBarOneOffElement = document.getAnonymousElementByAttribute(
   urlbarPopup, "anonid", "one-off-search-buttons"
 );
 
 let originalEngine = Services.search.currentEngine;
 
 function resetEngine() {
   Services.search.currentEngine = originalEngine;
 }
@@ -35,21 +35,21 @@ add_task(async function init() {
 
   await promiseNewEngine(TEST_ENGINE_BASENAME, {
     setAsCurrent: false,
   });
 });
 
 add_task(async function test_searchBarChangeEngine() {
   let oneOffButton = await openPopupAndGetEngineButton(true, searchPopup,
-                                                       searchOneOffBinding,
+                                                       searchOneOffElement,
                                                        SEARCHBAR_BASE_ID);
 
-  const setDefaultEngineMenuItem = document.getAnonymousElementByAttribute(
-    searchOneOffBinding, "anonid", "search-one-offs-context-set-default"
+  const setDefaultEngineMenuItem = searchOneOffElement.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.
   await promise;
@@ -69,21 +69,21 @@ 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,
-                                                       urlBarOneOffBinding,
+                                                       urlBarOneOffElement,
                                                        URLBAR_BASE_ID);
 
-  const setDefaultEngineMenuItem = document.getAnonymousElementByAttribute(
-    urlBarOneOffBinding, "anonid", "search-one-offs-context-set-default"
+  const setDefaultEngineMenuItem = urlBarOneOffElement.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.
   await promise;
@@ -123,44 +123,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} oneOffBinding The expected one-off-binding for the popup.
+ * @param {Object} oneOffElement The expected one-off-element 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, oneOffBinding, baseId) {
+async function openPopupAndGetEngineButton(isSearch, popup, oneOffElement, 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 = document.getAnonymousElementByAttribute(
-    oneOffBinding, "anonid", "search-one-offs-context-menu"
-  );
-  const oneOffButtons = document.getAnonymousElementByAttribute(
-    oneOffBinding, "anonid", "search-panel-one-offs"
-  );
+  const contextMenu = oneOffElement.contextMenuPopup;
+  const oneOffButtons = oneOffElement.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/search/test/browser_oneOffHeader.js
+++ b/browser/components/search/test/browser_oneOffHeader.js
@@ -5,22 +5,20 @@
 
 const isMac = ("nsILocalFileMac" in Ci);
 
 const searchPopup = document.getElementById("PopupSearchAutoComplete");
 
 const oneOffsContainer =
   document.getAnonymousElementByAttribute(searchPopup, "anonid",
                                           "search-one-off-buttons");
-const searchSettings =
-  document.getAnonymousElementByAttribute(oneOffsContainer, "anonid",
-                                          "search-settings");
-var header =
-  document.getAnonymousElementByAttribute(oneOffsContainer, "anonid",
-                                          "search-panel-one-offs-header");
+const searchSettings = oneOffsContainer.querySelector(".search-setting-button");
+
+var header = oneOffsContainer.querySelector(".search-panel-one-offs-header");
+
 function getHeaderText() {
   let headerChild = header.selectedPanel;
   while (headerChild.hasChildNodes()) {
     headerChild = headerChild.firstElementChild;
   }
   let headerStrings = [];
   for (let label = headerChild; label; label = label.nextElementSibling) {
     headerStrings.push(label.value);
--- a/browser/components/search/test/browser_searchbar_keyboard_navigation.js
+++ b/browser/components/search/test/browser_searchbar_keyboard_navigation.js
@@ -7,18 +7,17 @@ const oneOffsContainer =
 
 const kValues = ["foo1", "foo2", "foo3"];
 const kUserValue = "foo";
 
 function getOpenSearchItems() {
   let os = [];
 
   let addEngineList =
-    document.getAnonymousElementByAttribute(oneOffsContainer, "anonid",
-                                            "add-engines");
+    oneOffsContainer.querySelector(".search-add-engines");
   for (let item = addEngineList.firstElementChild; item; item = item.nextElementSibling)
     os.push(item);
 
   return os;
 }
 
 let searchbar;
 let textbox;
@@ -94,27 +93,27 @@ add_task(async function test_arrows() {
 
   // now cycle through the one-off items, the first one is already selected.
   for (let i = 0; i < oneOffs.length; ++i) {
     is(textbox.selectedButton, oneOffs[i],
        "the one-off button #" + (i + 1) + " should be selected");
     EventUtils.synthesizeKey("KEY_ArrowDown");
   }
 
-  is(textbox.selectedButton.getAttribute("anonid"), "search-settings",
+  ok(textbox.selectedButton.classList.contains("search-setting-button"),
      "the settings item should be selected");
   EventUtils.synthesizeKey("KEY_ArrowDown");
 
   // We should now be back to the initial situation.
   is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
   ok(!textbox.selectedButton, "no one-off button should be selected");
 
   info("now test the up arrow key");
   EventUtils.synthesizeKey("KEY_ArrowUp");
-  is(textbox.selectedButton.getAttribute("anonid"), "search-settings",
+  ok(textbox.selectedButton.classList.contains("search-setting-button"),
      "the settings item should be selected");
 
   // cycle through the one-off items, the first one is already selected.
   for (let i = oneOffs.length; i; --i) {
     EventUtils.synthesizeKey("KEY_ArrowUp");
     is(textbox.selectedButton, oneOffs[i - 1],
        "the one-off button #" + i + " should be selected");
   }
@@ -138,17 +137,17 @@ add_task(async function test_arrows() {
 });
 
 add_task(async function test_typing_clears_button_selection() {
   is(Services.focus.focusedElement, textbox.inputField,
      "the search bar should be focused"); // from the previous test.
   ok(!textbox.selectedButton, "no button should be selected");
 
   EventUtils.synthesizeKey("KEY_ArrowUp");
-  is(textbox.selectedButton.getAttribute("anonid"), "search-settings",
+  ok(textbox.selectedButton.classList.contains("search-setting-button"),
      "the settings item should be selected");
 
   // Type a character.
   EventUtils.sendString("a");
   ok(!textbox.selectedButton, "the settings item should be de-selected");
 
   // Remove the character.
   EventUtils.synthesizeKey("KEY_Backspace");
@@ -168,17 +167,17 @@ add_task(async function test_tab() {
     is(textbox.selectedButton, oneOffs[i],
        "the one-off button #" + (i + 1) + " should be selected");
   }
   is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
   is(textbox.value, kUserValue, "the textfield value should be unmodified");
 
   // One more <tab> selects the settings button.
   EventUtils.synthesizeKey("KEY_Tab");
-  is(textbox.selectedButton.getAttribute("anonid"), "search-settings",
+  ok(textbox.selectedButton.classList.contains("search-setting-button"),
      "the settings item should be selected");
 
   // Pressing tab again should close the panel...
   let promise = promiseEvent(searchPopup, "popuphidden");
   EventUtils.synthesizeKey("KEY_Tab");
   await promise;
 
   // ... and move the focus out of the searchbox.
@@ -193,17 +192,17 @@ add_task(async function test_shift_tab()
   searchbar.focus();
   await promise;
 
   let oneOffs = getOneOffs();
   ok(!textbox.selectedButton, "no one-off button should be selected");
 
   // Press up once to select the last button.
   EventUtils.synthesizeKey("KEY_ArrowUp");
-  is(textbox.selectedButton.getAttribute("anonid"), "search-settings",
+  ok(textbox.selectedButton.classList.contains("search-setting-button"),
      "the settings item should be selected");
 
   // Press up again to select the last one-off button.
   EventUtils.synthesizeKey("KEY_ArrowUp");
 
   // Pressing shift+tab should cycle through the one-off items.
   for (let i = oneOffs.length - 1; i >= 0; --i) {
     is(textbox.selectedButton, oneOffs[i],
@@ -298,17 +297,17 @@ add_task(async function test_alt_up() {
 
   // another one and the last one-off should be selected.
   EventUtils.synthesizeKey("KEY_ArrowUp", {altKey: true});
   is(textbox.selectedButton, oneOffs[oneOffs.length - 1],
      "the last one-off button should be selected");
 
   // Cleanup for the next test.
   EventUtils.synthesizeKey("KEY_ArrowDown");
-  is(textbox.selectedButton.getAttribute("anonid"), "search-settings",
+  ok(textbox.selectedButton.classList.contains("search-setting-button"),
      "the settings item should be selected");
   EventUtils.synthesizeKey("KEY_ArrowDown");
   ok(!textbox.selectedButton, "no one-off should be selected anymore");
 });
 
 add_task(async function test_tab_and_arrows() {
   // Check the initial state is as expected.
   ok(!textbox.selectedButton, "no one-off button should be selected");
@@ -389,17 +388,17 @@ add_task(async function test_open_search
   is(engines.length, 2, "the opensearch.html page exposes 2 engines");
 
   // Check that there's initially no selection.
   is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
   ok(!textbox.selectedButton, "no button should be selected");
 
   // Pressing up once selects the setting button...
   EventUtils.synthesizeKey("KEY_ArrowUp");
-  is(textbox.selectedButton.getAttribute("anonid"), "search-settings",
+  ok(textbox.selectedButton.classList.contains("search-setting-button"),
      "the settings item should be selected");
 
   // ...and then pressing up selects open search engines.
   for (let i = engines.length; i; --i) {
     EventUtils.synthesizeKey("KEY_ArrowUp");
     let selectedButton = textbox.selectedButton;
     is(selectedButton, engines[i - 1],
        "the engine #" + i + " should be selected");
@@ -416,17 +415,17 @@ add_task(async function test_open_search
   for (let i = 0; i < engines.length; ++i) {
     EventUtils.synthesizeKey("KEY_ArrowDown");
     is(textbox.selectedButton, engines[i],
        "the engine #" + (i + 1) + " should be selected");
   }
 
   // Pressing down on the last engine item selects the settings button.
   EventUtils.synthesizeKey("KEY_ArrowDown");
-  is(textbox.selectedButton.getAttribute("anonid"), "search-settings",
+  ok(textbox.selectedButton.classList.contains("search-setting-button"),
      "the settings item should be selected");
 
   promise = promiseEvent(searchPopup, "popuphidden");
   searchPopup.hidePopup();
   await promise;
 
   gBrowser.removeCurrentTab();
 });
--- a/browser/components/search/test/browser_searchbar_smallpanel_keyboard_navigation.js
+++ b/browser/components/search/test/browser_searchbar_smallpanel_keyboard_navigation.js
@@ -6,18 +6,17 @@ const oneOffsContainer =
                                           "search-one-off-buttons");
 
 const kValues = ["foo1", "foo2", "foo3"];
 
 function getOpenSearchItems() {
   let os = [];
 
   let addEngineList =
-    document.getAnonymousElementByAttribute(oneOffsContainer, "anonid",
-                                            "add-engines");
+    oneOffsContainer.querySelector(".search-add-engines");
   for (let item = addEngineList.firstElementChild; item; item = item.nextElementSibling)
     os.push(item);
 
   return os;
 }
 
 let searchbar;
 let textbox;
@@ -86,27 +85,27 @@ info("textbox.mController.searchString =
 
   // now cycle through the one-off items, the first one is already selected.
   for (let i = 0; i < oneOffs.length; ++i) {
     is(textbox.selectedButton, oneOffs[i],
        "the one-off button #" + (i + 1) + " should be selected");
     EventUtils.synthesizeKey("KEY_ArrowDown");
   }
 
-  is(textbox.selectedButton.getAttribute("anonid"), "search-settings",
+  ok(textbox.selectedButton.classList.contains("search-setting-button"),
      "the settings item should be selected");
   EventUtils.synthesizeKey("KEY_ArrowDown");
 
   // We should now be back to the initial situation.
   is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
   ok(!textbox.selectedButton, "no one-off button should be selected");
 
   info("now test the up arrow key");
   EventUtils.synthesizeKey("KEY_ArrowUp");
-  is(textbox.selectedButton.getAttribute("anonid"), "search-settings",
+  ok(textbox.selectedButton.classList.contains("search-setting-button"),
      "the settings item should be selected");
 
   // cycle through the one-off items, the first one is already selected.
   for (let i = oneOffs.length; i; --i) {
     EventUtils.synthesizeKey("KEY_ArrowUp");
     is(textbox.selectedButton, oneOffs[i - 1],
        "the one-off button #" + i + " should be selected");
   }
@@ -132,17 +131,17 @@ add_task(async function test_tab() {
     is(textbox.selectedButton, oneOffs[i],
        "the one-off button #" + (i + 1) + " should be selected");
   }
   is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
   is(textbox.value, "", "the textfield value should be unmodified");
 
   // One more <tab> selects the settings button.
   EventUtils.synthesizeKey("KEY_Tab");
-  is(textbox.selectedButton.getAttribute("anonid"), "search-settings",
+  ok(textbox.selectedButton.classList.contains("search-setting-button"),
      "the settings item should be selected");
 
   // Pressing tab again should close the panel...
   let promise = promiseEvent(searchPopup, "popuphidden");
   EventUtils.synthesizeKey("KEY_Tab");
   await promise;
 
   // ... and move the focus out of the searchbox.
@@ -160,17 +159,17 @@ add_task(async function test_shift_tab()
   await promise;
 
   let oneOffs = getOneOffs();
   ok(!textbox.selectedButton, "no one-off button should be selected");
   is(searchPopup.getAttribute("showonlysettings"), "true", "Should show the small popup");
 
   // Press up once to select the last button.
   EventUtils.synthesizeKey("KEY_ArrowUp");
-  is(textbox.selectedButton.getAttribute("anonid"), "search-settings",
+  ok(textbox.selectedButton.classList.contains("search-setting-button"),
      "the settings item should be selected");
 
   // Press up again to select the last one-off button.
   EventUtils.synthesizeKey("KEY_ArrowUp");
 
   // Pressing shift+tab should cycle through the one-off items.
   for (let i = oneOffs.length - 1; i >= 0; --i) {
     is(textbox.selectedButton, oneOffs[i],
@@ -252,17 +251,17 @@ add_task(async function test_alt_up() {
 
   // another one and the last one-off should be selected.
   EventUtils.synthesizeKey("KEY_ArrowUp", {altKey: true});
   is(textbox.selectedButton, oneOffs[oneOffs.length - 1],
      "the last one-off button should be selected");
 
   // Cleanup for the next test.
   EventUtils.synthesizeKey("KEY_ArrowDown");
-  is(textbox.selectedButton.getAttribute("anonid"), "search-settings",
+  ok(textbox.selectedButton.classList.contains("search-setting-button"),
      "the settings item should be selected");
   EventUtils.synthesizeKey("KEY_ArrowDown");
   ok(!textbox.selectedButton, "no one-off should be selected anymore");
 });
 
 add_task(async function test_tab_and_arrows() {
   // Check the initial state is as expected.
   ok(!textbox.selectedButton, "no one-off button should be selected");
@@ -308,17 +307,17 @@ add_task(async function test_open_search
   is(engines.length, 2, "the opensearch.html page exposes 2 engines");
 
   // Check that there's initially no selection.
   is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
   ok(!textbox.selectedButton, "no button should be selected");
 
   // Pressing up once selects the setting button...
   EventUtils.synthesizeKey("KEY_ArrowUp");
-  is(textbox.selectedButton.getAttribute("anonid"), "search-settings",
+  ok(textbox.selectedButton.classList.contains("search-setting-button"),
      "the settings item should be selected");
 
   // ...and then pressing up selects open search engines.
   for (let i = engines.length; i; --i) {
     EventUtils.synthesizeKey("KEY_ArrowUp");
     let selectedButton = textbox.selectedButton;
     is(selectedButton, engines[i - 1],
        "the engine #" + i + " should be selected");
@@ -335,17 +334,17 @@ add_task(async function test_open_search
   for (let i = 0; i < engines.length; ++i) {
     EventUtils.synthesizeKey("KEY_ArrowDown");
     is(textbox.selectedButton, engines[i],
        "the engine #" + (i + 1) + " should be selected");
   }
 
   // Pressing down on the last engine item selects the settings button.
   EventUtils.synthesizeKey("KEY_ArrowDown");
-  is(textbox.selectedButton.getAttribute("anonid"), "search-settings",
+  ok(textbox.selectedButton.classList.contains("search-setting-button"),
      "the settings item should be selected");
 
   promise = promiseEvent(searchPopup, "popuphidden");
   searchPopup.hidePopup();
   await promise;
 
   gBrowser.removeCurrentTab();
 });
--- a/browser/components/search/test/browser_tooManyEnginesOffered.js
+++ b/browser/components/search/test/browser_tooManyEnginesOffered.js
@@ -81,16 +81,14 @@ add_task(async function test() {
   Assert.ok(!menuButton.open, "Submenu should be closed");
 
   gBrowser.removeCurrentTab();
 });
 
 function getOpenSearchItems() {
   let os = [];
 
-  let addEngineList =
-    document.getAnonymousElementByAttribute(oneOffsContainer, "anonid",
-                                            "add-engines");
+  let addEngineList = oneOffsContainer.querySelector(".search-add-engines");
   for (let item = addEngineList.firstElementChild; item; item = item.nextElementSibling)
     os.push(item);
 
   return os;
 }
--- a/browser/components/search/test/head.js
+++ b/browser/components/search/test/head.js
@@ -183,18 +183,17 @@ function promiseTabLoadEvent(tab, url) {
 // Get an array of the one-off buttons.
 function getOneOffs() {
   let oneOffs = [];
   let searchPopup = document.getElementById("PopupSearchAutoComplete");
   let oneOffsContainer =
     document.getAnonymousElementByAttribute(searchPopup, "anonid",
                                             "search-one-off-buttons");
   let oneOff =
-    document.getAnonymousElementByAttribute(oneOffsContainer, "anonid",
-                                            "search-panel-one-offs");
+    oneOffsContainer.querySelector(".search-panel-one-offs");
   for (oneOff = oneOff.firstChild; oneOff; oneOff = oneOff.nextSibling) {
     if (oneOff.nodeType == Node.ELEMENT_NODE) {
       if (oneOff.classList.contains("dummy") ||
           oneOff.classList.contains("search-setting-button-compact"))
         break;
       oneOffs.push(oneOff);
     }
   }
--- a/browser/themes/shared/searchbar.inc.css
+++ b/browser/themes/shared/searchbar.inc.css
@@ -5,16 +5,20 @@
 .searchbar-engine-image {
   width: 16px;
   height: 16px;
   list-style-image: url("chrome://mozapps/skin/places/defaultFavicon.svg");
   -moz-context-properties: fill;
   fill: currentColor;
 }
 
+.search-one-offs {
+  -moz-box-orient: vertical;
+}
+
 /**
  * The borders of the various elements are specified as follows.
  *
  * The current engine always has a bottom border.
  * The search results never have a border.
  *
  * When the search results are not collapsed:
  * - The elements underneath the search results all have a top border.
@@ -99,20 +103,20 @@
   box-sizing: content-box;
   border-bottom: 1px solid var(--panel-separator-color);
 }
 
 .search-setting-button-compact {
   border-bottom: none !important;
 }
 
-.search-panel-one-offs:not([compact=true]) > .searchbar-engine-one-off-item.last-of-row,
-.search-panel-one-offs[compact=true] > .searchbar-engine-one-off-item.last-of-row:not(.dummy),
-.search-panel-one-offs[compact=true] > .searchbar-engine-one-off-item.dummy:not(.last-of-row),
-.search-panel-one-offs[compact=true] > .searchbar-engine-one-off-item.last-engine,
+.search-one-offs:not([compact=true]) .searchbar-engine-one-off-item.last-of-row,
+.search-one-offs[compact=true] .searchbar-engine-one-off-item.last-of-row:not(.dummy),
+.search-one-offs[compact=true] .searchbar-engine-one-off-item.dummy:not(.last-of-row),
+.search-one-offs[compact=true] .searchbar-engine-one-off-item.last-engine,
 .search-setting-button-compact {
   background-image: none;
 }
 
 .searchbar-engine-one-off-item:not([selected]):not(.dummy):hover,
 .addengine-item:hover {
   background-color: var(--arrowpanel-dimmed-further);
   color: inherit;