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 491360 ec90e4f6f2a2ff225bc8e49975deb5d0cc31bce7
parent 491359 f0ed99b29aafb5fd7f613a000634b53927af4259
child 491361 3c935139d3d84dde6579d5302fdd5d06a96d0f74
push id247
push userfmarier@mozilla.com
push dateSat, 27 Oct 2018 01:06:44 +0000
reviewersdao
bugs1493536
milestone65.0a1
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;