summaryrefslogtreecommitdiffstats
path: root/browser/components/search/SearchOneOffs.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/search/SearchOneOffs.sys.mjs')
-rw-r--r--browser/components/search/SearchOneOffs.sys.mjs1130
1 files changed, 1130 insertions, 0 deletions
diff --git a/browser/components/search/SearchOneOffs.sys.mjs b/browser/components/search/SearchOneOffs.sys.mjs
new file mode 100644
index 0000000000..ddab74f94a
--- /dev/null
+++ b/browser/components/search/SearchOneOffs.sys.mjs
@@ -0,0 +1,1130 @@
+/* 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/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+ SearchUIUtils: "resource:///modules/SearchUIUtils.sys.mjs",
+});
+
+const EMPTY_ADD_ENGINES = [];
+
+/**
+ * Defines the search one-off button elements. These are displayed at the bottom
+ * of the address bar and search bar. The address bar buttons are a subclass in
+ * browser/components/urlbar/UrlbarSearchOneOffs.jsm. If you are adding a new
+ * subclass, see "Methods for subclasses to override" below.
+ */
+export class SearchOneOffs {
+ constructor(container) {
+ this.container = container;
+ this.window = container.ownerGlobal;
+ this.document = container.ownerDocument;
+
+ this.container.appendChild(
+ this.window.MozXULElement.parseXULToFragment(
+ `
+ <hbox class="search-panel-one-offs-header search-panel-header">
+ <label class="search-panel-one-offs-header-label" data-l10n-id="search-one-offs-with-title"/>
+ </hbox>
+ <box class="search-panel-one-offs-container">
+ <hbox class="search-panel-one-offs" role="group"/>
+ <button class="searchbar-engine-one-off-item search-setting-button" tabindex="-1" data-l10n-id="search-one-offs-change-settings-compact-button"/>
+ </box>
+ <box>
+ <menupopup class="search-one-offs-context-menu">
+ <menuitem class="search-one-offs-context-open-in-new-tab" data-l10n-id="search-one-offs-context-open-new-tab"/>
+ <menuitem class="search-one-offs-context-set-default" data-l10n-id="search-one-offs-context-set-as-default"/>
+ <menuitem class="search-one-offs-context-set-default-private" data-l10n-id="search-one-offs-context-set-as-default-private"/>
+ </menupopup>
+ </box>
+ `
+ )
+ );
+
+ 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.settingsButton = this.querySelector(".search-setting-button");
+
+ this.contextMenuPopup = this.querySelector(".search-one-offs-context-menu");
+
+ this._engineInfo = null;
+
+ /**
+ * `_rebuild()` is async, because it queries the Search Service, which means
+ * there is a potential for a race when it's called multiple times in succession.
+ */
+ this._rebuilding = false;
+
+ this.addEventListener("mousedown", this);
+ this.addEventListener("click", this);
+ this.addEventListener("command", this);
+ this.addEventListener("contextmenu", this);
+
+ // Prevent popup events from the context menu from reaching the autocomplete
+ // binding (or other listeners).
+ let listener = aEvent => aEvent.stopPropagation();
+ this.contextMenuPopup.addEventListener("popupshowing", listener);
+ this.contextMenuPopup.addEventListener("popuphiding", listener);
+ this.contextMenuPopup.addEventListener("popupshown", aEvent => {
+ aEvent.stopPropagation();
+ });
+ this.contextMenuPopup.addEventListener("popuphidden", aEvent => {
+ aEvent.stopPropagation();
+ });
+
+ // Add weak referenced observers to invalidate our cached list of engines.
+ this.QueryInterface = ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]);
+ Services.prefs.addObserver("browser.search.hiddenOneOffs", this, true);
+ Services.obs.addObserver(this, "browser-search-engine-modified", true);
+ Services.obs.addObserver(this, "browser-search-service", true);
+
+ // Rebuild the buttons when the theme changes. See bug 1357800 for
+ // details. Summary: On Linux, switching between themes can cause a row
+ // of buttons to disappear.
+ Services.obs.addObserver(this, "lightweight-theme-changed", true);
+
+ // This defaults to false in the Search Bar, subclasses can change their
+ // default in the constructor.
+ this.disableOneOffsHorizontalKeyNavigation = false;
+ }
+
+ addEventListener(...args) {
+ this.container.addEventListener(...args);
+ }
+
+ removeEventListener(...args) {
+ this.container.removeEventListener(...args);
+ }
+
+ dispatchEvent(...args) {
+ this.container.dispatchEvent(...args);
+ }
+
+ getAttribute(...args) {
+ return this.container.getAttribute(...args);
+ }
+
+ hasAttribute(...args) {
+ return this.container.hasAttribute(...args);
+ }
+
+ setAttribute(...args) {
+ this.container.setAttribute(...args);
+ }
+
+ querySelector(...args) {
+ return this.container.querySelector(...args);
+ }
+
+ handleEvent(event) {
+ let methodName = "_on_" + event.type;
+ if (methodName in this) {
+ this[methodName](event);
+ } else {
+ throw new Error("Unrecognized search-one-offs event: " + event.type);
+ }
+ }
+
+ /**
+ * @returns {boolean}
+ * True if we will hide the one-offs when they are requested.
+ */
+ async willHide() {
+ if (this._engineInfo?.willHide !== undefined) {
+ return this._engineInfo.willHide;
+ }
+ let engineInfo = await this.getEngineInfo();
+ let oneOffCount = engineInfo.engines.length;
+ this._engineInfo.willHide =
+ !oneOffCount ||
+ (oneOffCount == 1 &&
+ engineInfo.engines[0].name == engineInfo.default.name);
+ return this._engineInfo.willHide;
+ }
+
+ /**
+ * Invalidates the engine cache. After invalidating the cache, the one-offs
+ * will be rebuilt the next time they are shown.
+ */
+ invalidateCache() {
+ if (!this._rebuilding) {
+ this._engineInfo = null;
+ }
+ }
+
+ /**
+ * Width in pixels of the one-off buttons.
+ * NOTE: Used in browser/components/search/content/searchbar.js only.
+ *
+ * @returns {number}
+ */
+ get buttonWidth() {
+ return 48;
+ }
+
+ /**
+ * The popup that contains the one-offs.
+ *
+ * @param {DOMElement} val
+ * The new value to set.
+ */
+ set popup(val) {
+ if (this._popup) {
+ this._popup.removeEventListener("popupshowing", this);
+ this._popup.removeEventListener("popuphidden", this);
+ }
+ if (val) {
+ val.addEventListener("popupshowing", this);
+ val.addEventListener("popuphidden", 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();
+ }
+ }
+
+ 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.
+ *
+ * @param {DOMElement} val
+ * The new value to set.
+ */
+ set textbox(val) {
+ if (this._textbox) {
+ this._textbox.removeEventListener("input", this);
+ }
+ if (val) {
+ val.addEventListener("input", this);
+ }
+ this._textbox = val;
+ }
+
+ get style() {
+ return this.container.style;
+ }
+
+ get textbox() {
+ return this._textbox;
+ }
+
+ /**
+ * The query string currently shown in the one-offs. If the textbox
+ * property is non-null, then this is automatically updated on
+ * input.
+ *
+ * @param {string} val
+ * The new query string to set.
+ */
+ set query(val) {
+ this._query = val;
+ if (this.isViewOpen) {
+ let isOneOffSelected =
+ this.selectedButton &&
+ this.selectedButton.classList.contains(
+ "searchbar-engine-one-off-item"
+ ) &&
+ !(
+ this.selectedButton == this.settingsButton &&
+ this.hasAttribute("is_searchbar")
+ );
+ // 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;
+ }
+ }
+ }
+
+ get query() {
+ return this._query;
+ }
+
+ /**
+ * The selected one-off, a xul:button, including the add-engine button
+ * and the search-settings button.
+ *
+ * @param {DOMElement|null} val
+ * The selected one-off button. Null if no one-off is selected.
+ */
+ set selectedButton(val) {
+ let previousButton = this._selectedButton;
+ if (previousButton) {
+ previousButton.removeAttribute("selected");
+ }
+ if (val) {
+ val.setAttribute("selected", "true");
+ }
+ this._selectedButton = val;
+
+ if (this.textbox) {
+ if (val) {
+ this.textbox.setAttribute("aria-activedescendant", val.id);
+ } else {
+ let active = this.textbox.getAttribute("aria-activedescendant");
+ if (active && active.includes("-engine-one-off-item-")) {
+ this.textbox.removeAttribute("aria-activedescendant");
+ }
+ }
+ }
+
+ let event = new CustomEvent("SelectedOneOffButtonChanged", {
+ previousSelectedButton: previousButton,
+ });
+ this.dispatchEvent(event);
+ }
+
+ get selectedButton() {
+ return this._selectedButton;
+ }
+
+ /**
+ * The index of the selected one-off, including the add-engine button
+ * and the search-settings button.
+ *
+ * @param {number} val
+ * The new index to set, -1 for nothing selected.
+ */
+ set selectedButtonIndex(val) {
+ let buttons = this.getSelectableButtons(true);
+ this.selectedButton = buttons[val];
+ }
+
+ get selectedButtonIndex() {
+ let buttons = this.getSelectableButtons(true);
+ for (let i = 0; i < buttons.length; i++) {
+ if (buttons[i] == this._selectedButton) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ async getEngineInfo() {
+ if (this._engineInfo) {
+ return this._engineInfo;
+ }
+
+ this._engineInfo = {};
+ if (lazy.PrivateBrowsingUtils.isWindowPrivate(this.window)) {
+ this._engineInfo.default = await Services.search.getDefaultPrivate();
+ } else {
+ this._engineInfo.default = await Services.search.getDefault();
+ }
+
+ let currentEngineNameToIgnore;
+ if (!this.getAttribute("includecurrentengine")) {
+ currentEngineNameToIgnore = this._engineInfo.default.name;
+ }
+
+ let pref = Services.prefs.getStringPref("browser.search.hiddenOneOffs");
+ let hiddenList = pref ? pref.split(",") : [];
+
+ this._engineInfo.engines = (
+ await Services.search.getVisibleEngines()
+ ).filter(e => {
+ let name = e.name;
+ return (
+ (!currentEngineNameToIgnore || name != currentEngineNameToIgnore) &&
+ !hiddenList.includes(name)
+ );
+ });
+
+ return this._engineInfo;
+ }
+
+ observe(aEngine, aTopic, aData) {
+ // Make sure the engine list was updated.
+ this.invalidateCache();
+ }
+
+ _getAddEngines() {
+ return this.window.gBrowser.selectedBrowser.engines || EMPTY_ADD_ENGINES;
+ }
+
+ get _maxInlineAddEngines() {
+ return 3;
+ }
+
+ /**
+ * Infallible, non-re-entrant version of `__rebuild()`.
+ */
+ async _rebuild() {
+ if (this._rebuilding) {
+ return;
+ }
+
+ this._rebuilding = true;
+ try {
+ await this.__rebuild();
+ } catch (ex) {
+ console.error("Search-one-offs::_rebuild() error: " + ex);
+ } finally {
+ this._rebuilding = false;
+ }
+ }
+
+ /**
+ * Builds all the UI.
+ */
+ async __rebuild() {
+ // Return early if the list of engines has not changed.
+ if (!this.popup && this._engineInfo?.domWasUpdated) {
+ return;
+ }
+
+ const addEngines = this._getAddEngines();
+
+ // Return early if the engines and panel width have not changed.
+ if (this.popup && this._textbox) {
+ let textboxWidth = await this.window.promiseDocumentFlushed(() => {
+ return this._textbox.clientWidth;
+ });
+
+ if (
+ this._engineInfo?.domWasUpdated &&
+ this._textboxWidth == textboxWidth &&
+ this._addEngines == addEngines
+ ) {
+ return;
+ }
+ this._textboxWidth = textboxWidth;
+ this._addEngines = addEngines;
+ }
+
+ const isSearchBar = this.hasAttribute("is_searchbar");
+ if (isSearchBar) {
+ // Hide the container during updating to avoid flickering.
+ this.container.hidden = true;
+ }
+
+ // Finally, build the list of one-off buttons.
+ while (this.buttons.firstElementChild) {
+ this.buttons.firstElementChild.remove();
+ }
+
+ let headerText = this.header.querySelector(
+ ".search-panel-one-offs-header-label"
+ );
+ headerText.id = this.telemetryOrigin + "-one-offs-header-label";
+ this.buttons.setAttribute("aria-labelledby", headerText.id);
+
+ // For the search-bar, always show the one-off buttons where there is an
+ // option to add an engine.
+ let addEngineNeeded = isSearchBar && addEngines.length;
+ let hideOneOffs = (await this.willHide()) && !addEngineNeeded;
+
+ // The _engineInfo cache is used by more consumers, thus it is not a good
+ // representation of whether this method already updated the one-off buttons
+ // DOM. For this reason we introduce a separate flag tracking the DOM
+ // updating, and use it to know when it's okay to not rebuild the one-offs.
+ // We set this early, since we might either rebuild the DOM or hide it.
+ this._engineInfo.domWasUpdated = true;
+
+ this.container.hidden = hideOneOffs;
+
+ if (hideOneOffs) {
+ return;
+ }
+
+ // Ensure we can refer to the settings buttons by ID:
+ let origin = this.telemetryOrigin;
+ this.settingsButton.id = origin + "-anon-search-settings";
+
+ let engines = (await this.getEngineInfo()).engines;
+ this._rebuildEngineList(engines, addEngines);
+
+ this.dispatchEvent(new Event("rebuild"));
+ }
+
+ /**
+ * Adds one-offs for the given engines to the DOM.
+ *
+ * @param {Array} engines
+ * The engines to add.
+ * @param {Array} addEngines
+ * The engines that can be added.
+ */
+ _rebuildEngineList(engines, addEngines) {
+ for (let i = 0; i < engines.length; ++i) {
+ let engine = engines[i];
+ let button = this.document.createXULElement("button");
+ button.engine = engine;
+ button.id = this._buttonIDForEngine(engine);
+ let iconURI =
+ engine.iconURI?.spec ||
+ "chrome://browser/skin/search-engine-placeholder.png";
+ button.setAttribute("image", iconURI);
+ button.setAttribute("class", "searchbar-engine-one-off-item");
+ button.setAttribute("tabindex", "-1");
+ this.setTooltipForEngineButton(button);
+ this.buttons.appendChild(button);
+ }
+
+ for (
+ let i = 0, len = Math.min(addEngines.length, this._maxInlineAddEngines);
+ i < len;
+ i++
+ ) {
+ const engine = addEngines[i];
+ const button = this.document.createXULElement("button");
+ button.id = this._buttonIDForEngine(engine);
+ button.classList.add("searchbar-engine-one-off-item");
+ button.classList.add("searchbar-engine-one-off-add-engine");
+ button.setAttribute("tabindex", "-1");
+ if (engine.icon) {
+ button.setAttribute("image", engine.icon);
+ }
+ button.setAttribute("data-l10n-id", "search-one-offs-add-engine");
+ button.setAttribute(
+ "data-l10n-args",
+ JSON.stringify({ engineName: engine.title })
+ );
+ button.setAttribute("engine-name", engine.title);
+ button.setAttribute("uri", engine.uri);
+ this.buttons.appendChild(button);
+ }
+ }
+
+ _buttonIDForEngine(engine) {
+ return (
+ this.telemetryOrigin +
+ "-engine-one-off-item-engine-" +
+ this._engineInfo.engines.indexOf(engine)
+ );
+ }
+
+ getSelectableButtons(aIncludeNonEngineButtons) {
+ const buttons = [
+ ...this.buttons.querySelectorAll(".searchbar-engine-one-off-item"),
+ ];
+
+ if (aIncludeNonEngineButtons) {
+ buttons.push(this.settingsButton);
+ }
+
+ return buttons;
+ }
+
+ /**
+ * Returns information on where a search results page should be loaded: in the
+ * current tab or a new tab.
+ *
+ * @param {event} aEvent
+ * The event that triggered the page load.
+ * @param {boolean} [aForceNewTab]
+ * True to force the load in a new tab.
+ * @returns {object} An object { where, params }. `where` is a string:
+ * "current" or "tab". `params` is an object further describing how
+ * the page should be loaded.
+ */
+ _whereToOpen(aEvent, aForceNewTab = false) {
+ 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 {
+ let newTabPref = Services.prefs.getBoolPref("browser.search.openintab");
+ if (
+ (KeyboardEvent.isInstance(aEvent) && aEvent.altKey) ^ newTabPref &&
+ !this.window.gBrowser.selectedTab.isEmpty
+ ) {
+ where = "tab";
+ }
+ if (
+ MouseEvent.isInstance(aEvent) &&
+ (aEvent.button == 1 || aEvent.getModifierState("Accel"))
+ ) {
+ where = "tab";
+ params = {
+ inBackground: true,
+ };
+ }
+ }
+
+ return { where, params };
+ }
+
+ /**
+ * Increments or decrements the index of the currently selected one-off.
+ *
+ * @param {boolean} aForward
+ * If true, the index is incremented, and if false, the index is
+ * decremented.
+ * @param {boolean} aIncludeNonEngineButtons
+ * If true, 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 {boolean} aWrapAround
+ * If true, the selection wraps around between the first and last
+ * buttons.
+ */
+ 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];
+ }
+
+ /**
+ * 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 it will call
+ * event.preventDefault() and return true.
+ *
+ * @param {Event} event
+ * The key event.
+ * @param {number} 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 {boolean} 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 {string} [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.)
+ * @returns {boolean} True if the one-offs handled the key press.
+ */
+ handleKeyDown(event, numListItems, allowEmptySelection, textboxUserValue) {
+ if (!this.hasView) {
+ return false;
+ }
+ let handled = this._handleKeyDown(
+ event,
+ numListItems,
+ allowEmptySelection,
+ textboxUserValue
+ );
+ if (handled) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ return handled;
+ }
+
+ _handleKeyDown(event, numListItems, allowEmptySelection, textboxUserValue) {
+ if (this.container.hidden) {
+ 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;
+ }
+
+ // 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.selectedViewIndex = -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.selectedViewIndex > 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.selectedViewIndex == 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.selectedViewIndex = -1;
+ 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.selectedViewIndex >= 0 &&
+ this.selectedViewIndex < 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.selectedViewIndex == numListItems - 1) {
+ // Moving down from the last item in the list to the buttons.
+ if (!allowEmptySelection) {
+ this.selectedViewIndex = -1;
+ if (this.textbox && typeof textboxUserValue == "string") {
+ this.textbox.value = textboxUserValue;
+ }
+ }
+ this.selectedButtonIndex = 0;
+ if (allowEmptySelection) {
+ // Let the autocomplete controller remove selection in the list
+ // and revert the typed text in the textbox.
+ return false;
+ }
+ 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.selectedButton.engine &&
+ !this.disableOneOffsHorizontalKeyNavigation
+ ) {
+ // Moving left within the buttons.
+ this.advanceSelection(false, true, true);
+ return true;
+ }
+ return false;
+ }
+
+ if (event.keyCode == KeyboardEvent.DOM_VK_RIGHT) {
+ if (
+ this.selectedButton &&
+ this.selectedButton.engine &&
+ !this.disableOneOffsHorizontalKeyNavigation
+ ) {
+ // Moving right within the buttons.
+ this.advanceSelection(true, true, true);
+ return true;
+ }
+ return false;
+ }
+
+ return false;
+ }
+
+ /**
+ * Determines if the target of the event is a one-off button or
+ * context menu on a one-off button.
+ *
+ * @param {Event} event
+ * An event, like a click on a one-off button.
+ * @returns {boolean} True if telemetry was recorded and false if not.
+ */
+ eventTargetIsAOneOff(event) {
+ if (!event) {
+ return false;
+ }
+
+ let target = event.originalTarget;
+
+ if (KeyboardEvent.isInstance(event) && this.selectedButton) {
+ return true;
+ }
+ if (
+ MouseEvent.isInstance(event) &&
+ target.classList.contains("searchbar-engine-one-off-item")
+ ) {
+ return true;
+ }
+ if (
+ this.window.XULCommandEvent.isInstance(event) &&
+ target.classList.contains("search-one-offs-context-open-in-new-tab")
+ ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ // Methods for subclasses to override
+
+ /**
+ * @returns {boolean} True if the one-offs are connected to a view.
+ */
+ get hasView() {
+ return !!this.popup;
+ }
+
+ /**
+ * @returns {boolean} True if the view is open.
+ */
+ get isViewOpen() {
+ return this.popup && this.popup.popupOpen;
+ }
+
+ /**
+ * @returns {number} The selected index in the view or -1 if no selection.
+ */
+ get selectedViewIndex() {
+ return this.popup.selectedIndex;
+ }
+
+ /**
+ * Sets the selected index in the view.
+ *
+ * @param {number} val
+ * The selected index or -1 if no selection.
+ */
+ set selectedViewIndex(val) {
+ this.popup.selectedIndex = val;
+ }
+
+ /**
+ * Closes the view.
+ */
+ closeView() {
+ this.popup.hidePopup();
+ }
+
+ /**
+ * Called when a one-off is clicked or the "Search in New Tab" context menu
+ * item is picked. This is not called for the settings button.
+ *
+ * @param {event} event
+ * The event that triggered the pick.
+ * @param {nsISearchEngine|SearchEngine} engine
+ * The engine that was picked.
+ * @param {boolean} forceNewTab
+ * True if the search results page should be loaded in a new tab.
+ */
+ handleSearchCommand(event, engine, forceNewTab = false) {
+ let { where, params } = this._whereToOpen(event, forceNewTab);
+ this.popup.handleOneOffSearch(event, engine, where, params);
+ }
+
+ /**
+ * Sets the tooltip for a one-off button with an engine. This should set
+ * either the `tooltiptext` attribute or the relevant l10n ID.
+ *
+ * @param {element} button
+ * The one-off button.
+ */
+ setTooltipForEngineButton(button) {
+ button.setAttribute("tooltiptext", button.engine.name);
+ }
+
+ // Event handlers below.
+
+ _on_mousedown(event) {
+ // This is necessary to prevent the input from losing focus and closing the
+ // popup. Unfortunately it also has the side effect of preventing the
+ // buttons from receiving the `:active` pseudo-class.
+ event.preventDefault();
+ }
+
+ _on_click(event) {
+ if (event.button == 2) {
+ return; // ignore right clicks.
+ }
+
+ let button = event.originalTarget;
+ let engine = button.engine;
+
+ if (!engine) {
+ return;
+ }
+
+ // Select the clicked button so that consumers can easily tell which
+ // button was acted on.
+ this.selectedButton = button;
+ this.handleSearchCommand(event, engine);
+ }
+
+ _on_command(event) {
+ let target = event.target;
+
+ if (target == this.settingsButton) {
+ this.window.openPreferences("paneSearch");
+
+ // If the preference tab was already selected, the panel doesn't
+ // close itself automatically.
+ this.closeView();
+ return;
+ }
+
+ if (target.classList.contains("searchbar-engine-one-off-add-engine")) {
+ // On success, hide the panel and tell event listeners to reshow it to
+ // show the new engine.
+ lazy.SearchUIUtils.addOpenSearchEngine(
+ target.getAttribute("uri"),
+ target.getAttribute("image"),
+ this.window.gBrowser.selectedBrowser.browsingContext
+ )
+ .then(result => {
+ if (result) {
+ this._rebuild();
+ }
+ })
+ .catch(console.error);
+ return;
+ }
+
+ 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 = target.closest("menupopup")._triggerButton;
+ this.handleSearchCommand(event, this.selectedButton.engine, true);
+ }
+
+ const isPrivateButton = target.classList.contains(
+ "search-one-offs-context-set-default-private"
+ );
+ if (
+ target.classList.contains("search-one-offs-context-set-default") ||
+ isPrivateButton
+ ) {
+ const engineType = isPrivateButton
+ ? "defaultPrivateEngine"
+ : "defaultEngine";
+ let currentEngine = Services.search[engineType];
+
+ const isPrivateWin = lazy.PrivateBrowsingUtils.isWindowPrivate(
+ this.window
+ );
+ let button = target.closest("menupopup")._triggerButton;
+ // We're about to replace this, so it must be stored now.
+ let newDefaultEngine = button.engine;
+ if (
+ !this.getAttribute("includecurrentengine") &&
+ isPrivateButton == isPrivateWin
+ ) {
+ // 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 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;
+ }
+
+ if (isPrivateButton) {
+ Services.search.setDefaultPrivate(
+ newDefaultEngine,
+ Ci.nsISearchService.CHANGE_REASON_USER_SEARCHBAR_CONTEXT
+ );
+ } else {
+ Services.search.setDefault(
+ newDefaultEngine,
+ Ci.nsISearchService.CHANGE_REASON_USER_SEARCHBAR_CONTEXT
+ );
+ }
+ }
+ }
+
+ _on_contextmenu(event) {
+ let target = event.originalTarget;
+ // Prevent the context menu from appearing except on the one off buttons.
+ if (
+ !target.classList.contains("searchbar-engine-one-off-item") ||
+ target.classList.contains("search-setting-button")
+ ) {
+ event.preventDefault();
+ return;
+ }
+ this.contextMenuPopup
+ .querySelector(".search-one-offs-context-set-default")
+ .setAttribute(
+ "disabled",
+ target.engine == Services.search.defaultEngine.wrappedJSObject
+ );
+
+ const privateDefaultItem = this.contextMenuPopup.querySelector(
+ ".search-one-offs-context-set-default-private"
+ );
+
+ if (
+ Services.prefs.getBoolPref(
+ "browser.search.separatePrivateDefault.ui.enabled",
+ false
+ ) &&
+ Services.prefs.getBoolPref("browser.search.separatePrivateDefault", false)
+ ) {
+ privateDefaultItem.hidden = false;
+ privateDefaultItem.setAttribute(
+ "disabled",
+ target.engine == Services.search.defaultPrivateEngine.wrappedJSObject
+ );
+ } else {
+ privateDefaultItem.hidden = true;
+ }
+
+ // When a context menu is opened on a one-off button, this is set to the
+ // button to be used for the command.
+ this.contextMenuPopup._triggerButton = target;
+ this.contextMenuPopup.openPopupAtScreen(event.screenX, event.screenY, true);
+ event.preventDefault();
+ }
+
+ _on_input(event) {
+ // Allow the consumer's input to override its value property with
+ // a oneOffSearchQuery property. That way if the value is not
+ // actually what the user typed (e.g., it's autofilled, or it's a
+ // mozaction URI), the consumer has some way of providing it.
+ this.query = event.target.oneOffSearchQuery || event.target.value;
+ }
+
+ _on_popupshowing() {
+ this._rebuild();
+ }
+
+ _on_popuphidden() {
+ this.selectedButton = null;
+ }
+}