tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

SearchOneOffs.sys.mjs (37640B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 const lazy = {};
      6 
      7 ChromeUtils.defineESModuleGetters(lazy, {
      8  OpenSearchManager:
      9    "moz-src:///browser/components/search/OpenSearchManager.sys.mjs",
     10  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
     11  SearchUIUtils: "moz-src:///browser/components/search/SearchUIUtils.sys.mjs",
     12 });
     13 
     14 /**
     15 * @import {UrlbarUtils} from "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs"
     16 */
     17 
     18 /**
     19 * @typedef {object} LegacySearchButton
     20 * @property {boolean} open
     21 *   Whether the button is in an open state.
     22 * @property {Values<typeof UrlbarUtils.RESULT_SOURCE>} [source]
     23 *   The result source of the button. Only appropriate for one-off buttons
     24 *   on the urlbar.
     25 * @property {nsISearchEngine} engine
     26 *   The search engine associated with the button.
     27 */
     28 
     29 /**
     30 *  A XULElement augmented at runtime with additional properties.
     31 *
     32 *  @typedef {XULElement & LegacySearchButton} LegacySearchOneOffButton
     33 */
     34 
     35 /**
     36 * Defines the search one-off button elements. These are displayed at the bottom
     37 * of the address bar and search bar. The address bar buttons are a subclass in
     38 * browser/components/urlbar/UrlbarSearchOneOffs.sys.mjs. If you are adding a new
     39 * subclass, see "Methods for subclasses to override" below.
     40 */
     41 export class SearchOneOffs {
     42  constructor(container) {
     43    this.container = container;
     44    this.window = container.ownerGlobal;
     45    this.document = container.ownerDocument;
     46 
     47    this.container.appendChild(
     48      this.window.MozXULElement.parseXULToFragment(
     49        `
     50      <hbox class="search-panel-one-offs-header search-panel-header">
     51        <label class="search-panel-one-offs-header-label" data-l10n-id="search-one-offs-with-title"/>
     52      </hbox>
     53      <box class="search-panel-one-offs-container">
     54        <hbox class="search-panel-one-offs" role="group"/>
     55        <button class="searchbar-engine-one-off-item search-setting-button" tabindex="-1" data-l10n-id="search-one-offs-change-settings-compact-button"/>
     56      </box>
     57      <box>
     58        <menupopup class="search-one-offs-context-menu">
     59          <menuitem class="search-one-offs-context-open-in-new-tab" data-l10n-id="search-one-offs-context-open-new-tab"/>
     60          <menuitem class="search-one-offs-context-set-default" data-l10n-id="search-one-offs-context-set-as-default"/>
     61          <menuitem class="search-one-offs-context-set-default-private" data-l10n-id="search-one-offs-context-set-as-default-private"/>
     62        </menupopup>
     63      </box>
     64      `
     65      )
     66    );
     67 
     68    this._popup = null;
     69    this._textbox = null;
     70 
     71    this._textboxWidth = 0;
     72 
     73    /**
     74     * Set this to a string that identifies your one-offs consumer.  It'll
     75     * be appended to telemetry recorded with maybeRecordTelemetry().
     76     */
     77    this.telemetryOrigin = "";
     78 
     79    this._query = "";
     80 
     81    this._selectedButton = null;
     82 
     83    this.buttons = this.querySelector(".search-panel-one-offs");
     84 
     85    this.header = this.querySelector(".search-panel-one-offs-header");
     86 
     87    this.settingsButton = this.querySelector(".search-setting-button");
     88 
     89    this.contextMenuPopup = this.querySelector(".search-one-offs-context-menu");
     90 
     91    this._engineInfo = null;
     92 
     93    /**
     94     * `_rebuild()` is async, because it queries the Search Service, which means
     95     * there is a potential for a race when it's called multiple times in succession.
     96     */
     97    this._rebuilding = false;
     98 
     99    this.addEventListener("mousedown", this);
    100    this.addEventListener("click", this);
    101    this.addEventListener("command", this);
    102    this.addEventListener("contextmenu", this);
    103 
    104    // Prevent popup events from the context menu from reaching the autocomplete
    105    // binding (or other listeners).
    106    let listener = aEvent => aEvent.stopPropagation();
    107    this.contextMenuPopup.addEventListener("popupshowing", listener);
    108    this.contextMenuPopup.addEventListener("popuphiding", listener);
    109    this.contextMenuPopup.addEventListener("popupshown", aEvent => {
    110      aEvent.stopPropagation();
    111    });
    112    this.contextMenuPopup.addEventListener("popuphidden", aEvent => {
    113      aEvent.stopPropagation();
    114    });
    115 
    116    // Add weak referenced observers to invalidate our cached list of engines.
    117    this.QueryInterface = ChromeUtils.generateQI([
    118      "nsIObserver",
    119      "nsISupportsWeakReference",
    120    ]);
    121    Services.obs.addObserver(this, "browser-search-engine-modified", true);
    122    Services.obs.addObserver(this, "browser-search-service", true);
    123 
    124    // Rebuild the buttons when the theme changes.  See bug 1357800 for
    125    // details.  Summary: On Linux, switching between themes can cause a row
    126    // of buttons to disappear.
    127    Services.obs.addObserver(this, "lightweight-theme-changed", true);
    128 
    129    // This defaults to false in the Search Bar, subclasses can change their
    130    // default in the constructor.
    131    this.disableOneOffsHorizontalKeyNavigation = false;
    132  }
    133 
    134  addEventListener(...args) {
    135    this.container.addEventListener(...args);
    136  }
    137 
    138  removeEventListener(...args) {
    139    this.container.removeEventListener(...args);
    140  }
    141 
    142  dispatchEvent(...args) {
    143    this.container.dispatchEvent(...args);
    144  }
    145 
    146  getAttribute(...args) {
    147    return this.container.getAttribute(...args);
    148  }
    149 
    150  hasAttribute(...args) {
    151    return this.container.hasAttribute(...args);
    152  }
    153 
    154  setAttribute(...args) {
    155    this.container.setAttribute(...args);
    156  }
    157 
    158  querySelector(...args) {
    159    return this.container.querySelector(...args);
    160  }
    161 
    162  handleEvent(event) {
    163    let methodName = "_on_" + event.type;
    164    if (methodName in this) {
    165      this[methodName](event);
    166    } else {
    167      throw new Error("Unrecognized search-one-offs event: " + event.type);
    168    }
    169  }
    170 
    171  /**
    172   * @returns {Promise<boolean>}
    173   *   True if we will hide the one-offs when they are requested.
    174   */
    175  async willHide() {
    176    if (this._engineInfo?.willHide !== undefined) {
    177      return this._engineInfo.willHide;
    178    }
    179    let engineInfo = await this.getEngineInfo();
    180    let oneOffCount = engineInfo.engines.length;
    181    this._engineInfo.willHide =
    182      !oneOffCount ||
    183      (oneOffCount == 1 &&
    184        engineInfo.engines[0].name == engineInfo.default.name);
    185    return this._engineInfo.willHide;
    186  }
    187 
    188  /**
    189   * Invalidates the engine cache. After invalidating the cache, the one-offs
    190   * will be rebuilt the next time they are shown.
    191   */
    192  invalidateCache() {
    193    if (!this._rebuilding) {
    194      this._engineInfo = null;
    195    }
    196  }
    197 
    198  /**
    199   * Width in pixels of the one-off buttons.
    200   * NOTE: Used in browser/components/search/content/searchbar.js only.
    201   *
    202   * @returns {number}
    203   */
    204  get buttonWidth() {
    205    return 48;
    206  }
    207 
    208  /**
    209   * The popup that contains the one-offs.
    210   *
    211   * @param {XULPopupElement} val
    212   *        The new value to set.
    213   */
    214  set popup(val) {
    215    if (this._popup) {
    216      this._popup.removeEventListener("popupshowing", this);
    217      this._popup.removeEventListener("popuphidden", this);
    218    }
    219    if (val) {
    220      val.addEventListener("popupshowing", this);
    221      val.addEventListener("popuphidden", this);
    222    }
    223    this._popup = val;
    224 
    225    // If the popup is already open, rebuild the one-offs now.  The
    226    // popup may be opening, so check that the state is not closed
    227    // instead of checking popupOpen.
    228    if (val && val.state != "closed") {
    229      this._rebuild();
    230    }
    231  }
    232 
    233  get popup() {
    234    return this._popup;
    235  }
    236 
    237  /**
    238   * The textbox associated with the one-offs.  Set this to a textbox to
    239   * automatically keep the related one-offs UI up to date.  Otherwise you
    240   * can leave it null/undefined, and in that case you should update the
    241   * query property manually.
    242   *
    243   * @param {HTMLInputElement} val
    244   *        The new value to set.
    245   */
    246  set textbox(val) {
    247    if (this._textbox) {
    248      this._textbox.removeEventListener("input", this);
    249    }
    250    if (val) {
    251      val.addEventListener("input", this);
    252    }
    253    this._textbox = val;
    254  }
    255 
    256  get style() {
    257    return this.container.style;
    258  }
    259 
    260  get textbox() {
    261    return this._textbox;
    262  }
    263 
    264  /**
    265   * The query string currently shown in the one-offs.  If the textbox
    266   * property is non-null, then this is automatically updated on
    267   * input.
    268   *
    269   * @param {string} val
    270   *        The new query string to set.
    271   */
    272  set query(val) {
    273    this._query = val;
    274    if (this.isViewOpen) {
    275      let isOneOffSelected =
    276        this.selectedButton &&
    277        this.selectedButton.classList.contains(
    278          "searchbar-engine-one-off-item"
    279        ) &&
    280        !(
    281          this.selectedButton == this.settingsButton &&
    282          this.hasAttribute("is_searchbar")
    283        );
    284      // Typing de-selects the settings or opensearch buttons at the bottom
    285      // of the search panel, as typing shows the user intends to search.
    286      if (this.selectedButton && !isOneOffSelected) {
    287        this.selectedButton = null;
    288      }
    289    }
    290  }
    291 
    292  get query() {
    293    return this._query;
    294  }
    295 
    296  /**
    297   * The selected one-off including the add-engine button
    298   * and the search-settings button.
    299   *
    300   * @param {LegacySearchOneOffButton|null} val
    301   *        The selected one-off button. Null if no one-off is selected.
    302   */
    303  set selectedButton(val) {
    304    let previousButton = this._selectedButton;
    305    if (previousButton) {
    306      previousButton.removeAttribute("selected");
    307    }
    308    if (val) {
    309      val.toggleAttribute("selected", true);
    310    }
    311    this._selectedButton = val;
    312 
    313    if (this.textbox) {
    314      if (val) {
    315        this.textbox.setAttribute("aria-activedescendant", val.id);
    316      } else {
    317        let active = this.textbox.getAttribute("aria-activedescendant");
    318        if (active && active.includes("-engine-one-off-item-")) {
    319          this.textbox.removeAttribute("aria-activedescendant");
    320        }
    321      }
    322    }
    323 
    324    this.dispatchEvent(new CustomEvent("SelectedOneOffButtonChanged"));
    325  }
    326 
    327  get selectedButton() {
    328    return this._selectedButton;
    329  }
    330 
    331  /**
    332   * The index of the selected one-off, including the add-engine button
    333   * and the search-settings button.
    334   *
    335   * @param {number} val
    336   *        The new index to set, -1 for nothing selected.
    337   */
    338  set selectedButtonIndex(val) {
    339    let buttons = this.getSelectableButtons(true);
    340    this.selectedButton = buttons[val];
    341  }
    342 
    343  get selectedButtonIndex() {
    344    let buttons = this.getSelectableButtons(true);
    345    for (let i = 0; i < buttons.length; i++) {
    346      if (buttons[i] == this._selectedButton) {
    347        return i;
    348      }
    349    }
    350    return -1;
    351  }
    352 
    353  async getEngineInfo() {
    354    if (this._engineInfo) {
    355      return this._engineInfo;
    356    }
    357 
    358    this._engineInfo = {};
    359    if (lazy.PrivateBrowsingUtils.isWindowPrivate(this.window)) {
    360      this._engineInfo.default = await Services.search.getDefaultPrivate();
    361    } else {
    362      this._engineInfo.default = await Services.search.getDefault();
    363    }
    364 
    365    let currentEngineNameToIgnore;
    366    if (!this.getAttribute("includecurrentengine")) {
    367      currentEngineNameToIgnore = this._engineInfo.default.name;
    368    }
    369 
    370    this._engineInfo.engines = (
    371      await Services.search.getVisibleEngines()
    372    ).filter(e => {
    373      let name = e.name;
    374      return (
    375        (!currentEngineNameToIgnore || name != currentEngineNameToIgnore) &&
    376        !e.hideOneOffButton
    377      );
    378    });
    379 
    380    return this._engineInfo;
    381  }
    382 
    383  observe(aEngine, aTopic, aData) {
    384    // For the "browser-search-service" topic, we only need to invalidate
    385    // the cache on initialization complete or when the engines are reloaded.
    386    if (aTopic != "browser-search-service" || aData == "engines-reloaded") {
    387      // Make sure the engine list was updated.
    388      this.invalidateCache();
    389    }
    390 
    391    if (aData === "engine-icon-changed") {
    392      aEngine.getIconURL().then(icon => {
    393        this.getSelectableButtons(false)
    394          .find(b => b.engine?.id == aEngine.id)
    395          ?.setAttribute(
    396            "image",
    397            icon || "chrome://browser/skin/search-engine-placeholder.png"
    398          );
    399      });
    400    }
    401  }
    402 
    403  get _maxInlineAddEngines() {
    404    return 3;
    405  }
    406 
    407  /**
    408   * Infallible, non-re-entrant version of `__rebuild()`.
    409   */
    410  async _rebuild() {
    411    if (this._rebuilding) {
    412      return;
    413    }
    414 
    415    this._rebuilding = true;
    416    try {
    417      await this.__rebuild();
    418    } catch (ex) {
    419      console.error("Search-one-offs::_rebuild() error:", ex);
    420    } finally {
    421      this._rebuilding = false;
    422      this.dispatchEvent(new Event("rebuild"));
    423    }
    424  }
    425 
    426  /**
    427   * Builds all the UI.
    428   */
    429  async __rebuild() {
    430    // Return early if the list of engines has not changed.
    431    if (!this.popup && this._engineInfo?.domWasUpdated) {
    432      return;
    433    }
    434 
    435    const addEngines = lazy.OpenSearchManager.getEngines(
    436      this.window.gBrowser.selectedBrowser
    437    );
    438 
    439    // Return early if the engines and panel width have not changed.
    440    if (this.popup && this._textbox) {
    441      let textboxWidth = await this.window.promiseDocumentFlushed(() => {
    442        return this._textbox.clientWidth;
    443      });
    444 
    445      if (
    446        this._engineInfo?.domWasUpdated &&
    447        this._textboxWidth == textboxWidth &&
    448        this._addEngines == addEngines
    449      ) {
    450        return;
    451      }
    452      this._textboxWidth = textboxWidth;
    453      this._addEngines = addEngines;
    454    }
    455 
    456    const isSearchBar = this.hasAttribute("is_searchbar");
    457    if (isSearchBar) {
    458      // Hide the container during updating to avoid flickering.
    459      this.container.hidden = true;
    460    }
    461 
    462    // Finally, build the list of one-off buttons.
    463    while (this.buttons.firstElementChild) {
    464      this.buttons.firstElementChild.remove();
    465    }
    466 
    467    let headerText = this.header.querySelector(
    468      ".search-panel-one-offs-header-label"
    469    );
    470    headerText.id = this.telemetryOrigin + "-one-offs-header-label";
    471    this.buttons.setAttribute("aria-labelledby", headerText.id);
    472 
    473    // For the search-bar, always show the one-off buttons where there is an
    474    // option to add an engine.
    475    let addEngineNeeded = isSearchBar && addEngines.length;
    476    let hideOneOffs = (await this.willHide()) && !addEngineNeeded;
    477 
    478    // The _engineInfo cache is used by more consumers, thus it is not a good
    479    // representation of whether this method already updated the one-off buttons
    480    // DOM. For this reason we introduce a separate flag tracking the DOM
    481    // updating, and use it to know when it's okay to not rebuild the one-offs.
    482    // We set this early, since we might either rebuild the DOM or hide it.
    483    this._engineInfo.domWasUpdated = true;
    484 
    485    this.container.hidden = hideOneOffs;
    486 
    487    if (hideOneOffs) {
    488      return;
    489    }
    490 
    491    // Ensure we can refer to the settings buttons by ID:
    492    let origin = this.telemetryOrigin;
    493    this.settingsButton.id = origin + "-anon-search-settings";
    494 
    495    let engines = (await this.getEngineInfo()).engines;
    496    await this._rebuildEngineList(engines, addEngines);
    497  }
    498 
    499  /**
    500   * Adds one-offs for the given engines to the DOM.
    501   *
    502   * @param {Array} engines
    503   *        The engines to add.
    504   * @param {Array} addEngines
    505   *        The engines that can be added.
    506   */
    507  async _rebuildEngineList(engines, addEngines) {
    508    for (let i = 0; i < engines.length; ++i) {
    509      let engine = engines[i];
    510      let button = this.document.createXULElement("button");
    511      button.engine = engine;
    512      button.id = this._buttonIDForEngine(engine);
    513      let iconURL =
    514        (await engine.getIconURL()) ||
    515        "chrome://browser/skin/search-engine-placeholder.png";
    516      button.setAttribute("image", iconURL);
    517      button.setAttribute("class", "searchbar-engine-one-off-item");
    518      button.setAttribute("tabindex", "-1");
    519      this.setTooltipForEngineButton(button);
    520      this.buttons.appendChild(button);
    521    }
    522 
    523    for (
    524      let i = 0, len = Math.min(addEngines.length, this._maxInlineAddEngines);
    525      i < len;
    526      i++
    527    ) {
    528      const engine = addEngines[i];
    529      const button = this.document.createXULElement("button");
    530      button.id = this._buttonIDForEngine(engine);
    531      button.classList.add("searchbar-engine-one-off-item");
    532      button.classList.add("searchbar-engine-one-off-add-engine");
    533      button.setAttribute("tabindex", "-1");
    534      if (engine.icon) {
    535        button.setAttribute("image", engine.icon);
    536      }
    537      this.document.l10n.setAttributes(button, "search-one-offs-add-engine", {
    538        engineName: engine.title,
    539      });
    540      button.setAttribute("engine-name", engine.title);
    541      button.setAttribute("uri", engine.uri);
    542      this.buttons.appendChild(button);
    543    }
    544  }
    545 
    546  _buttonIDForEngine(engine) {
    547    return (
    548      this.telemetryOrigin +
    549      "-engine-one-off-item-engine-" +
    550      this._engineInfo.engines.indexOf(engine)
    551    );
    552  }
    553 
    554  getSelectableButtons(aIncludeNonEngineButtons) {
    555    const buttons = [
    556      ...this.buttons.querySelectorAll(".searchbar-engine-one-off-item"),
    557    ];
    558 
    559    if (aIncludeNonEngineButtons) {
    560      buttons.push(this.settingsButton);
    561    }
    562 
    563    return buttons;
    564  }
    565 
    566  /**
    567   * Returns information on where a search results page should be loaded: in the
    568   * current tab or a new tab.
    569   *
    570   * @param {event} aEvent
    571   *        The event that triggered the page load.
    572   * @param {boolean} [aForceNewTab]
    573   *        True to force the load in a new tab.
    574   * @returns {object} An object { where, params }.  `where` is a string:
    575   *          "current" or "tab".  `params` is an object further describing how
    576   *          the page should be loaded.
    577   */
    578  _whereToOpen(aEvent, aForceNewTab = false) {
    579    let where = "current";
    580    let params;
    581    // Open ctrl/cmd clicks on one-off buttons in a new background tab.
    582    if (aForceNewTab) {
    583      where = "tab";
    584      if (Services.prefs.getBoolPref("browser.tabs.loadInBackground")) {
    585        params = {
    586          inBackground: true,
    587        };
    588      }
    589    } else {
    590      let newTabPref = Services.prefs.getBoolPref("browser.search.openintab");
    591      if (
    592        (KeyboardEvent.isInstance(aEvent) && aEvent.altKey) != newTabPref &&
    593        !this.window.gBrowser.selectedTab.isEmpty
    594      ) {
    595        where = "tab";
    596      }
    597      if (
    598        MouseEvent.isInstance(aEvent) &&
    599        (aEvent.button == 1 || aEvent.getModifierState("Accel"))
    600      ) {
    601        where = "tab";
    602        params = {
    603          inBackground: true,
    604        };
    605      }
    606    }
    607 
    608    return { where, params };
    609  }
    610 
    611  /**
    612   * Increments or decrements the index of the currently selected one-off.
    613   *
    614   * @param {boolean} aForward
    615   *        If true, the index is incremented, and if false, the index is
    616   *        decremented.
    617   * @param {boolean} aIncludeNonEngineButtons
    618   *        If true, buttons that do not have engines are included.
    619   *        These buttons include the OpenSearch and settings buttons.  For
    620   *        example, if the currently selected button is an engine button,
    621   *        the next button is the settings button, and you pass true for
    622   *        aForward, then passing true for this value would cause the
    623   *        settings to be selected.  Passing false for this value would
    624   *        cause the selection to clear or wrap around, depending on what
    625   *        value you passed for the aWrapAround parameter.
    626   * @param {boolean} aWrapAround
    627   *        If true, the selection wraps around between the first and last
    628   *        buttons.
    629   */
    630  advanceSelection(aForward, aIncludeNonEngineButtons, aWrapAround) {
    631    let buttons = this.getSelectableButtons(aIncludeNonEngineButtons);
    632    let index;
    633    if (this.selectedButton) {
    634      let inc = aForward ? 1 : -1;
    635      let oldIndex = buttons.indexOf(this.selectedButton);
    636      index = (oldIndex + inc + buttons.length) % buttons.length;
    637      if (
    638        !aWrapAround &&
    639        ((aForward && index <= oldIndex) || (!aForward && oldIndex <= index))
    640      ) {
    641        // The index has wrapped around, but wrapping around isn't
    642        // allowed.
    643        index = -1;
    644      }
    645    } else {
    646      index = aForward ? 0 : buttons.length - 1;
    647    }
    648    this.selectedButton = index < 0 ? null : buttons[index];
    649  }
    650 
    651  /**
    652   * This handles key presses specific to the one-off buttons like Tab and
    653   * Alt+Up/Down, and Up/Down keys within the buttons.  Since one-off buttons
    654   * are always used in conjunction with a list of some sort (in this.popup),
    655   * it also handles Up/Down keys that cross the boundaries between list
    656   * items and the one-off buttons.
    657   *
    658   * If this method handles the key press, then it will call
    659   * event.preventDefault() and return true.
    660   *
    661   * @param {Event} event
    662   *        The key event.
    663   * @param {number} numListItems
    664   *        The number of items in the list.  The reason that this is a
    665   *        parameter at all is that the list may contain items at the end
    666   *        that should be ignored, depending on the consumer.  That's true
    667   *        for the urlbar for example.
    668   * @param {boolean} allowEmptySelection
    669   *        Pass true if it's OK that neither the list nor the one-off
    670   *        buttons contains a selection.  Pass false if either the list or
    671   *        the one-off buttons (or both) should always contain a selection.
    672   * @param {string} [textboxUserValue]
    673   *        When the last list item is selected and the user presses Down,
    674   *        the first one-off becomes selected and the textbox value is
    675   *        restored to the value that the user typed.  Pass that value here.
    676   *        However, if you pass true for allowEmptySelection, you don't need
    677   *        to pass anything for this parameter.  (Pass undefined or null.)
    678   * @returns {boolean} True if the one-offs handled the key press.
    679   */
    680  handleKeyDown(event, numListItems, allowEmptySelection, textboxUserValue) {
    681    if (!this.hasView) {
    682      return false;
    683    }
    684    let handled = this._handleKeyDown(
    685      event,
    686      numListItems,
    687      allowEmptySelection,
    688      textboxUserValue
    689    );
    690    if (handled) {
    691      event.preventDefault();
    692      event.stopPropagation();
    693    }
    694    return handled;
    695  }
    696 
    697  _handleKeyDown(event, numListItems, allowEmptySelection, textboxUserValue) {
    698    if (this.container.hidden) {
    699      return false;
    700    }
    701    if (
    702      event.keyCode == KeyEvent.DOM_VK_RIGHT &&
    703      this.selectedButton &&
    704      this.selectedButton.classList.contains("addengine-menu-button")
    705    ) {
    706      // If the add-engine overflow menu item is selected and the user
    707      // presses the right arrow key, open the submenu.  Unfortunately
    708      // handling the left arrow key -- to close the popup -- isn't
    709      // straightforward.  Once the popup is open, it consumes all key
    710      // events.  Setting ignorekeys=handled on it doesn't help, since the
    711      // popup handles all arrow keys.  Setting ignorekeys=true on it does
    712      // mean that the popup no longer consumes the left arrow key, but
    713      // then it no longer handles up/down keys to select items in the
    714      // popup.
    715      this.selectedButton.open = true;
    716      return true;
    717    }
    718 
    719    // Handle the Tab key, but only if non-Shift modifiers aren't also
    720    // pressed to avoid clobbering other shortcuts (like the Alt+Tab
    721    // browser tab switcher).  The reason this uses getModifierState() and
    722    // checks for "AltGraph" is that when you press Shift-Alt-Tab,
    723    // event.altKey is actually false for some reason, at least on macOS.
    724    // getModifierState("Alt") is also false, but "AltGraph" is true.
    725    if (
    726      event.keyCode == KeyEvent.DOM_VK_TAB &&
    727      !event.getModifierState("Alt") &&
    728      !event.getModifierState("AltGraph") &&
    729      !event.getModifierState("Control") &&
    730      !event.getModifierState("Meta")
    731    ) {
    732      if (
    733        this.getAttribute("disabletab") == "true" ||
    734        (event.shiftKey && this.selectedButtonIndex <= 0) ||
    735        (!event.shiftKey &&
    736          this.selectedButtonIndex ==
    737            this.getSelectableButtons(true).length - 1)
    738      ) {
    739        this.selectedButton = null;
    740        return false;
    741      }
    742      this.selectedViewIndex = -1;
    743      this.advanceSelection(!event.shiftKey, true, false);
    744      return !!this.selectedButton;
    745    }
    746 
    747    if (event.keyCode == KeyboardEvent.DOM_VK_UP) {
    748      if (event.altKey) {
    749        // Keep the currently selected result in the list (if any) as a
    750        // secondary "alt" selection and move the selection up within the
    751        // buttons.
    752        this.advanceSelection(false, false, false);
    753        return true;
    754      }
    755      if (numListItems == 0) {
    756        this.advanceSelection(false, true, false);
    757        return true;
    758      }
    759      if (this.selectedViewIndex > 0) {
    760        // Moving up within the list.  The autocomplete controller should
    761        // handle this case.  A button may be selected, so null it.
    762        this.selectedButton = null;
    763        return false;
    764      }
    765      if (this.selectedViewIndex == 0) {
    766        // Moving up from the top of the list.
    767        if (allowEmptySelection) {
    768          // Let the autocomplete controller remove selection in the list
    769          // and revert the typed text in the textbox.
    770          return false;
    771        }
    772        // Wrap selection around to the last button.
    773        if (this.textbox && typeof textboxUserValue == "string") {
    774          this.textbox.value = textboxUserValue;
    775        }
    776        this.selectedViewIndex = -1;
    777        this.advanceSelection(false, true, true);
    778        return true;
    779      }
    780      if (!this.selectedButton) {
    781        // Moving up from no selection in the list or the buttons, back
    782        // down to the last button.
    783        this.advanceSelection(false, true, true);
    784        return true;
    785      }
    786      if (this.selectedButtonIndex == 0) {
    787        // Moving up from the buttons to the bottom of the list.
    788        this.selectedButton = null;
    789        return false;
    790      }
    791      // Moving up/left within the buttons.
    792      this.advanceSelection(false, true, false);
    793      return true;
    794    }
    795 
    796    if (event.keyCode == KeyboardEvent.DOM_VK_DOWN) {
    797      if (event.altKey) {
    798        // Keep the currently selected result in the list (if any) as a
    799        // secondary "alt" selection and move the selection down within
    800        // the buttons.
    801        this.advanceSelection(true, false, false);
    802        return true;
    803      }
    804      if (numListItems == 0) {
    805        this.advanceSelection(true, true, false);
    806        return true;
    807      }
    808      if (
    809        this.selectedViewIndex >= 0 &&
    810        this.selectedViewIndex < numListItems - 1
    811      ) {
    812        // Moving down within the list.  The autocomplete controller
    813        // should handle this case.  A button may be selected, so null it.
    814        this.selectedButton = null;
    815        return false;
    816      }
    817      if (this.selectedViewIndex == numListItems - 1) {
    818        // Moving down from the last item in the list to the buttons.
    819        if (!allowEmptySelection) {
    820          this.selectedViewIndex = -1;
    821          if (this.textbox && typeof textboxUserValue == "string") {
    822            this.textbox.value = textboxUserValue;
    823          }
    824        }
    825        this.selectedButtonIndex = 0;
    826        if (allowEmptySelection) {
    827          // Let the autocomplete controller remove selection in the list
    828          // and revert the typed text in the textbox.
    829          return false;
    830        }
    831        return true;
    832      }
    833      if (this.selectedButton) {
    834        let buttons = this.getSelectableButtons(true);
    835        if (this.selectedButtonIndex == buttons.length - 1) {
    836          // Moving down from the buttons back up to the top of the list.
    837          this.selectedButton = null;
    838          if (allowEmptySelection) {
    839            // Prevent the selection from wrapping around to the top of
    840            // the list by returning true, since the list currently has no
    841            // selection.  Nothing should be selected after handling this
    842            // Down key.
    843            return true;
    844          }
    845          return false;
    846        }
    847        // Moving down/right within the buttons.
    848        this.advanceSelection(true, true, false);
    849        return true;
    850      }
    851      return false;
    852    }
    853 
    854    if (event.keyCode == KeyboardEvent.DOM_VK_LEFT) {
    855      if (
    856        this.selectedButton &&
    857        this.selectedButton.engine &&
    858        !this.disableOneOffsHorizontalKeyNavigation
    859      ) {
    860        // Moving left within the buttons.
    861        this.advanceSelection(false, true, true);
    862        return true;
    863      }
    864      return false;
    865    }
    866 
    867    if (event.keyCode == KeyboardEvent.DOM_VK_RIGHT) {
    868      if (
    869        this.selectedButton &&
    870        this.selectedButton.engine &&
    871        !this.disableOneOffsHorizontalKeyNavigation
    872      ) {
    873        // Moving right within the buttons.
    874        this.advanceSelection(true, true, true);
    875        return true;
    876      }
    877      return false;
    878    }
    879 
    880    return false;
    881  }
    882 
    883  /**
    884   * Determines if the target of the event is a one-off button or
    885   * context menu on a one-off button.
    886   *
    887   * @param {Event} event
    888   *        An event, like a click on a one-off button.
    889   * @returns {boolean} True if telemetry was recorded and false if not.
    890   */
    891  eventTargetIsAOneOff(event) {
    892    if (!event) {
    893      return false;
    894    }
    895 
    896    let target = event.originalTarget;
    897 
    898    if (KeyboardEvent.isInstance(event) && this.selectedButton) {
    899      return true;
    900    }
    901    if (
    902      MouseEvent.isInstance(event) &&
    903      Element.isInstance(target) &&
    904      target.classList.contains("searchbar-engine-one-off-item")
    905    ) {
    906      return true;
    907    }
    908    if (
    909      this.window.XULCommandEvent.isInstance(event) &&
    910      Element.isInstance(target) &&
    911      target.classList.contains("search-one-offs-context-open-in-new-tab")
    912    ) {
    913      return true;
    914    }
    915 
    916    return false;
    917  }
    918 
    919  // Methods for subclasses to override
    920 
    921  /**
    922   * @returns {boolean} True if the one-offs are connected to a view.
    923   */
    924  get hasView() {
    925    return !!this.popup;
    926  }
    927 
    928  /**
    929   * @returns {boolean} True if the view is open.
    930   */
    931  get isViewOpen() {
    932    // @ts-expect-error - MozSearchAutocompleteRichlistboxPopup is defined in JS and lacks type declarations.
    933    return this.popup && this.popup.popupOpen;
    934  }
    935 
    936  /**
    937   * @returns {number} The selected index in the view or -1 if no selection.
    938   */
    939  get selectedViewIndex() {
    940    // @ts-expect-error - MozSearchAutocompleteRichlistboxPopup is defined in JS and lacks type declarations.
    941    return this.popup.selectedIndex;
    942  }
    943 
    944  /**
    945   * Sets the selected index in the view.
    946   *
    947   * @param {number} val
    948   *        The selected index or -1 if no selection.
    949   */
    950  set selectedViewIndex(val) {
    951    // @ts-expect-error - MozSearchAutocompleteRichlistboxPopup is defined in JS and lacks type declarations.
    952    this.popup.selectedIndex = val;
    953  }
    954 
    955  /**
    956   * Closes the view.
    957   */
    958  closeView() {
    959    this.popup.hidePopup();
    960  }
    961 
    962  /**
    963   * Called when a one-off is clicked or the "Search in New Tab" context menu
    964   * item is picked.  This is not called for the settings button.
    965   *
    966   * @param {event} event
    967   *        The event that triggered the pick.
    968   * @param {nsISearchEngine} engine
    969   *        The engine that was picked.
    970   * @param {boolean} forceNewTab
    971   *        True if the search results page should be loaded in a new tab.
    972   */
    973  handleSearchCommand(event, engine, forceNewTab = false) {
    974    let { where, params } = this._whereToOpen(event, forceNewTab);
    975    // @ts-expect-error - MozSearchAutocompleteRichlistboxPopup is defined in JS and lacks type declarations.
    976    this.popup.handleOneOffSearch(event, engine, where, params);
    977  }
    978 
    979  /**
    980   * Sets the tooltip for a one-off button with an engine.  This should set
    981   * either the `tooltiptext` attribute or the relevant l10n ID.
    982   *
    983   * @param {LegacySearchOneOffButton} button
    984   *        The one-off button.
    985   */
    986  setTooltipForEngineButton(button) {
    987    button.setAttribute("tooltiptext", button.engine.name);
    988  }
    989 
    990  // Event handlers below.
    991 
    992  _on_mousedown(event) {
    993    // This is necessary to prevent the input from losing focus and closing the
    994    // popup. Unfortunately it also has the side effect of preventing the
    995    // buttons from receiving the `:active` pseudo-class.
    996    event.preventDefault();
    997  }
    998 
    999  _on_click(event) {
   1000    if (event.button == 2) {
   1001      return; // ignore right clicks.
   1002    }
   1003 
   1004    let button = event.originalTarget;
   1005    let engine = button.engine;
   1006 
   1007    if (!engine) {
   1008      return;
   1009    }
   1010 
   1011    if (!this.textbox.value) {
   1012      if (event.shiftKey) {
   1013        // @ts-expect-error - MozSearchAutocompleteRichlistboxPopup is defined in JS and lacks type declarations.
   1014        this.popup.openSearchForm(event, engine);
   1015      }
   1016      return;
   1017    }
   1018    // Select the clicked button so that consumers can easily tell which
   1019    // button was acted on.
   1020    this.selectedButton = button;
   1021    this.handleSearchCommand(event, engine);
   1022  }
   1023 
   1024  async _on_command(event) {
   1025    let target = event.target;
   1026 
   1027    if (target == this.settingsButton) {
   1028      this.window.openPreferences("paneSearch");
   1029 
   1030      // If the preference tab was already selected, the panel doesn't
   1031      // close itself automatically.
   1032      this.closeView();
   1033      return;
   1034    }
   1035 
   1036    if (target.classList.contains("searchbar-engine-one-off-add-engine")) {
   1037      // On success, hide the panel and tell event listeners to reshow it to
   1038      // show the new engine.
   1039      lazy.SearchUIUtils.addOpenSearchEngine(
   1040        target.getAttribute("uri"),
   1041        target.getAttribute("image"),
   1042        this.window.gBrowser.selectedBrowser.browsingContext
   1043      )
   1044        .then(result => {
   1045          if (result) {
   1046            this._rebuild();
   1047          }
   1048        })
   1049        .catch(console.error);
   1050      return;
   1051    }
   1052 
   1053    if (target.classList.contains("search-one-offs-context-open-in-new-tab")) {
   1054      // Select the context-clicked button so that consumers can easily
   1055      // tell which button was acted on.
   1056      this.selectedButton = target.closest("menupopup")._triggerButton;
   1057      if (this.textbox.value) {
   1058        this.handleSearchCommand(event, this.selectedButton.engine, true);
   1059      } else {
   1060        // @ts-expect-error - MozSearchAutocompleteRichlistboxPopup is defined in JS and lacks type declarations.
   1061        this.popup.openSearchForm(event, this.selectedButton.engine, true);
   1062      }
   1063    }
   1064 
   1065    const isPrivateButton = target.classList.contains(
   1066      "search-one-offs-context-set-default-private"
   1067    );
   1068    if (
   1069      target.classList.contains("search-one-offs-context-set-default") ||
   1070      isPrivateButton
   1071    ) {
   1072      const engineType = isPrivateButton
   1073        ? "defaultPrivateEngine"
   1074        : "defaultEngine";
   1075      let currentEngine = Services.search[engineType];
   1076 
   1077      const isPrivateWin = lazy.PrivateBrowsingUtils.isWindowPrivate(
   1078        this.window
   1079      );
   1080      let button = target.closest("menupopup")._triggerButton;
   1081      // We're about to replace this, so it must be stored now.
   1082      let newDefaultEngine = button.engine;
   1083      if (
   1084        !this.getAttribute("includecurrentengine") &&
   1085        isPrivateButton == isPrivateWin
   1086      ) {
   1087        // Make the target button of the context menu reflect the current
   1088        // search engine first. Doing this as opposed to rebuilding all the
   1089        // one-off buttons avoids flicker.
   1090        let iconURL =
   1091          (await currentEngine.getIconURL()) ||
   1092          "chrome://browser/skin/search-engine-placeholder.png";
   1093        button.setAttribute("image", iconURL);
   1094        button.setAttribute("tooltiptext", currentEngine.name);
   1095        button.engine = currentEngine;
   1096      }
   1097 
   1098      if (isPrivateButton) {
   1099        Services.search.setDefaultPrivate(
   1100          newDefaultEngine,
   1101          Ci.nsISearchService.CHANGE_REASON_USER_SEARCHBAR_CONTEXT
   1102        );
   1103      } else {
   1104        Services.search.setDefault(
   1105          newDefaultEngine,
   1106          Ci.nsISearchService.CHANGE_REASON_USER_SEARCHBAR_CONTEXT
   1107        );
   1108      }
   1109    }
   1110  }
   1111 
   1112  _on_contextmenu(event) {
   1113    let target = event.originalTarget;
   1114    // Prevent the context menu from appearing except on the one off buttons.
   1115    if (
   1116      !target.classList.contains("searchbar-engine-one-off-item") ||
   1117      target.classList.contains("search-setting-button")
   1118    ) {
   1119      event.preventDefault();
   1120      return;
   1121    }
   1122    this.contextMenuPopup
   1123      .querySelector(".search-one-offs-context-set-default")
   1124      .setAttribute(
   1125        "disabled",
   1126        target.engine == Services.search.defaultEngine.wrappedJSObject
   1127      );
   1128 
   1129    const privateDefaultItem = this.contextMenuPopup.querySelector(
   1130      ".search-one-offs-context-set-default-private"
   1131    );
   1132 
   1133    if (
   1134      Services.prefs.getBoolPref(
   1135        "browser.search.separatePrivateDefault.ui.enabled",
   1136        false
   1137      ) &&
   1138      Services.prefs.getBoolPref("browser.search.separatePrivateDefault", false)
   1139    ) {
   1140      privateDefaultItem.hidden = false;
   1141      privateDefaultItem.setAttribute(
   1142        "disabled",
   1143        target.engine == Services.search.defaultPrivateEngine.wrappedJSObject
   1144      );
   1145    } else {
   1146      privateDefaultItem.hidden = true;
   1147    }
   1148 
   1149    // When a context menu is opened on a one-off button, this is set to the
   1150    // button to be used for the command.
   1151    this.contextMenuPopup._triggerButton = target;
   1152    this.contextMenuPopup.openPopupAtScreen(event.screenX, event.screenY, true);
   1153    event.preventDefault();
   1154  }
   1155 
   1156  _on_input(event) {
   1157    // Allow the consumer's input to override its value property with
   1158    // a oneOffSearchQuery property.  That way if the value is not
   1159    // actually what the user typed (e.g., it's autofilled, or it's a
   1160    // mozaction URI), the consumer has some way of providing it.
   1161    this.query = event.target.oneOffSearchQuery || event.target.value;
   1162  }
   1163 
   1164  _on_popupshowing() {
   1165    this._rebuild();
   1166  }
   1167 
   1168  _on_popuphidden() {
   1169    this.selectedButton = null;
   1170  }
   1171 }