summaryrefslogtreecommitdiffstats
path: root/browser/components/search/content
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/search/content')
-rw-r--r--browser/components/search/content/autocomplete-popup.js289
-rw-r--r--browser/components/search/content/contentSearchHandoffUI.js152
-rw-r--r--browser/components/search/content/contentSearchUI.css160
-rw-r--r--browser/components/search/content/contentSearchUI.js1021
-rw-r--r--browser/components/search/content/searchbar.js895
5 files changed, 2517 insertions, 0 deletions
diff --git a/browser/components/search/content/autocomplete-popup.js b/browser/components/search/content/autocomplete-popup.js
new file mode 100644
index 0000000000..7732f2bbb2
--- /dev/null
+++ b/browser/components/search/content/autocomplete-popup.js
@@ -0,0 +1,289 @@
+/* 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/. */
+
+"use strict";
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ ChromeUtils.defineESModuleGetters(this, {
+ SearchOneOffs: "resource:///modules/SearchOneOffs.sys.mjs",
+ });
+
+ /**
+ * A richlistbox popup custom element for for a browser search autocomplete
+ * widget.
+ */
+ class MozSearchAutocompleteRichlistboxPopup extends MozElements.MozAutocompleteRichlistboxPopup {
+ constructor() {
+ super();
+
+ this.addEventListener("popupshowing", event => {
+ // 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.
+ if (this.searchbar.hasAttribute("showonlysettings")) {
+ this.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;
+ }
+
+ // Show the current default engine in the top header of the panel.
+ this.updateHeader().catch(console.error);
+
+ this._oneOffButtons.addEventListener(
+ "SelectedOneOffButtonChanged",
+ this
+ );
+ });
+
+ this.addEventListener("popuphiding", event => {
+ this._oneOffButtons.removeEventListener(
+ "SelectedOneOffButtonChanged",
+ this
+ );
+ });
+
+ /**
+ * This handles clicks on the topmost "Foo Search" header in the
+ * popup (hbox.search-panel-header]).
+ */
+ this.addEventListener("click", event => {
+ 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);
+ });
+
+ this._bundle = null;
+ }
+
+ static get inheritedAttributes() {
+ return {
+ ".search-panel-current-engine": "showonlysettings",
+ ".searchbar-engine-image": "src",
+ };
+ }
+
+ // We override this because even though we have a shadow root, we want our
+ // inheritance to be done on the light tree.
+ getElementForAttrInheritance(selector) {
+ return this.querySelector(selector);
+ }
+
+ initialize() {
+ super.initialize();
+ this.initializeAttributeInheritance();
+
+ this._searchOneOffsContainer = this.querySelector(".search-one-offs");
+ this._searchbarEngine = this.querySelector(".search-panel-header");
+ this._searchbarEngineName = this.querySelector(".searchbar-engine-name");
+ this._oneOffButtons = new SearchOneOffs(this._searchOneOffsContainer);
+ this._searchbar = document.getElementById("searchbar");
+ }
+
+ get oneOffButtons() {
+ if (!this._oneOffButtons) {
+ this.initialize();
+ }
+ return this._oneOffButtons;
+ }
+
+ static get markup() {
+ return `
+ <hbox class="search-panel-header search-panel-current-engine">
+ <image class="searchbar-engine-image"/>
+ <label class="searchbar-engine-name" flex="1" crop="end" role="presentation"/>
+ </hbox>
+ <menuseparator class="searchbar-separator"/>
+ <richlistbox class="autocomplete-richlistbox search-panel-tree"/>
+ <menuseparator class="searchbar-separator"/>
+ <hbox class="search-one-offs" is_searchbar="true"/>
+ `;
+ }
+
+ get searchOneOffsContainer() {
+ if (!this._searchOneOffsContainer) {
+ this.initialize();
+ }
+ return this._searchOneOffsContainer;
+ }
+
+ get searchbarEngine() {
+ if (!this._searchbarEngine) {
+ this.initialize();
+ }
+ return this._searchbarEngine;
+ }
+
+ get searchbarEngineName() {
+ if (!this._searchbarEngineName) {
+ this.initialize();
+ }
+ return this._searchbarEngineName;
+ }
+
+ get searchbar() {
+ if (!this._searchbar) {
+ this.initialize();
+ }
+ return this._searchbar;
+ }
+
+ get bundle() {
+ if (!this._bundle) {
+ const kBundleURI = "chrome://browser/locale/search.properties";
+ this._bundle = Services.strings.createBundle(kBundleURI);
+ }
+ return this._bundle;
+ }
+
+ openAutocompletePopup(aInput, aElement) {
+ // 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);
+ }
+
+ onPopupClick(aEvent) {
+ // Ignore all right-clicks
+ if (aEvent.button == 2) {
+ return;
+ }
+
+ this.searchbar.telemetrySelectedIndex = this.selectedIndex;
+
+ // 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
+ BrowserSearchTelemetry.recordSearchSuggestionSelectionMethod(
+ aEvent,
+ "searchbar",
+ this.selectedIndex
+ );
+
+ // Handle search bar popup clicks
+ let search = this.input.controller.getValueAt(this.selectedIndex);
+
+ // open the search results according to the clicking subtlety
+ let 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" &&
+ MouseEvent.isInstance(aEvent) &&
+ (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();
+ }
+
+ this.searchbar.doSearch(search, where, null, params);
+ if (where == "tab" && params.inBackground) {
+ this.searchbar.focus();
+ } else {
+ this.searchbar.value = search;
+ }
+ }
+
+ async updateHeader(engine) {
+ if (!engine) {
+ if (PrivateBrowsingUtils.isWindowPrivate(window)) {
+ engine = await Services.search.getDefaultPrivate();
+ } else {
+ engine = await Services.search.getDefault();
+ }
+ }
+
+ let uri = engine.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", [
+ engine.name,
+ ]);
+ this.searchbarEngineName.setAttribute("value", headerText);
+ this.searchbarEngine.engine = engine;
+ }
+
+ /**
+ * This is called when a one-off is clicked and when "search in new tab"
+ * is selected from a one-off context menu.
+ */
+ /* eslint-disable-next-line valid-jsdoc */
+ handleOneOffSearch(event, engine, where, params) {
+ this.searchbar.handleSearchCommandWhere(event, engine, where, params);
+ }
+
+ /**
+ * Passes DOM events for the popup to the _on_<event type> methods.
+ *
+ * @param {Event} event
+ * DOM event from the <popup>.
+ */
+ handleEvent(event) {
+ let methodName = "_on_" + event.type;
+ if (methodName in this) {
+ this[methodName](event);
+ } else {
+ throw new Error("Unrecognized UrlbarView event: " + event.type);
+ }
+ }
+ _on_SelectedOneOffButtonChanged() {
+ let engine =
+ this.oneOffButtons.selectedButton &&
+ this.oneOffButtons.selectedButton.engine;
+ this.updateHeader(engine).catch(console.error);
+ }
+ }
+
+ customElements.define(
+ "search-autocomplete-richlistbox-popup",
+ MozSearchAutocompleteRichlistboxPopup,
+ {
+ extends: "panel",
+ }
+ );
+}
diff --git a/browser/components/search/content/contentSearchHandoffUI.js b/browser/components/search/content/contentSearchHandoffUI.js
new file mode 100644
index 0000000000..7c2aaa71b7
--- /dev/null
+++ b/browser/components/search/content/contentSearchHandoffUI.js
@@ -0,0 +1,152 @@
+/* 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/. */
+
+"use strict";
+
+function ContentSearchHandoffUIController() {
+ this._isPrivateEngine = false;
+ this._isAboutPrivateBrowsing = false;
+ this._engineIcon = null;
+
+ window.addEventListener("ContentSearchService", this);
+ this._sendMsg("GetEngine");
+ this._sendMsg("GetHandoffSearchModePrefs");
+}
+
+ContentSearchHandoffUIController.prototype = {
+ handleEvent(event) {
+ let methodName = "_onMsg" + event.detail.type;
+ if (methodName in this) {
+ this[methodName](event.detail.data);
+ }
+ },
+
+ get defaultEngine() {
+ return this._defaultEngine;
+ },
+
+ _onMsgEngine({ isPrivateEngine, isAboutPrivateBrowsing, engine }) {
+ this._isPrivateEngine = isPrivateEngine;
+ this._isAboutPrivateBrowsing = isAboutPrivateBrowsing;
+ this._updateEngine(engine);
+ },
+
+ _onMsgCurrentEngine(engine) {
+ if (!this._isPrivateEngine) {
+ this._updateEngine(engine);
+ }
+ },
+
+ _onMsgCurrentPrivateEngine(engine) {
+ if (this._isPrivateEngine) {
+ this._updateEngine(engine);
+ }
+ },
+
+ _onMsgHandoffSearchModePrefs(pref) {
+ this._shouldHandOffToSearchMode = pref;
+ this._updatel10nIds();
+ },
+
+ _updateEngine(engine) {
+ this._defaultEngine = engine;
+ if (this._engineIcon) {
+ URL.revokeObjectURL(this._engineIcon);
+ }
+
+ // We only show the engines icon for app provided engines, otherwise show
+ // a default. xref https://bugzilla.mozilla.org/show_bug.cgi?id=1449338#c19
+ if (!engine.isAppProvided) {
+ this._engineIcon = "chrome://global/skin/icons/search-glass.svg";
+ } else if (engine.iconData) {
+ this._engineIcon = this._getFaviconURIFromIconData(engine.iconData);
+ } else {
+ this._engineIcon = "chrome://global/skin/icons/defaultFavicon.svg";
+ }
+
+ document.body.style.setProperty(
+ "--newtab-search-icon",
+ "url(" + this._engineIcon + ")"
+ );
+ this._updatel10nIds();
+ },
+
+ _updatel10nIds() {
+ let engine = this._defaultEngine;
+ let fakeButton = document.querySelector(".search-handoff-button");
+ let fakeInput = document.querySelector(".fake-textbox");
+ if (!fakeButton || !fakeInput) {
+ return;
+ }
+ if (!engine || this._shouldHandOffToSearchMode) {
+ document.l10n.setAttributes(
+ fakeButton,
+ this._isAboutPrivateBrowsing
+ ? "about-private-browsing-search-btn"
+ : "newtab-search-box-input"
+ );
+ document.l10n.setAttributes(
+ fakeInput,
+ this._isAboutPrivateBrowsing
+ ? "about-private-browsing-search-placeholder"
+ : "newtab-search-box-text"
+ );
+ } else if (!engine.isAppProvided) {
+ document.l10n.setAttributes(
+ fakeButton,
+ this._isAboutPrivateBrowsing
+ ? "about-private-browsing-handoff-no-engine"
+ : "newtab-search-box-handoff-input-no-engine"
+ );
+ document.l10n.setAttributes(
+ fakeInput,
+ this._isAboutPrivateBrowsing
+ ? "about-private-browsing-handoff-text-no-engine"
+ : "newtab-search-box-handoff-text-no-engine"
+ );
+ } else {
+ document.l10n.setAttributes(
+ fakeButton,
+ this._isAboutPrivateBrowsing
+ ? "about-private-browsing-handoff"
+ : "newtab-search-box-handoff-input",
+ {
+ engine: engine.name,
+ }
+ );
+ document.l10n.setAttributes(
+ fakeInput,
+ this._isAboutPrivateBrowsing
+ ? "about-private-browsing-handoff-text"
+ : "newtab-search-box-handoff-text",
+ {
+ engine: engine.name,
+ }
+ );
+ }
+ },
+
+ // If the favicon is an array buffer, convert it into a Blob URI.
+ // Otherwise just return the plain URI.
+ _getFaviconURIFromIconData(data) {
+ if (typeof data === "string") {
+ return data;
+ }
+
+ // If typeof(data) != "string", we assume it's an ArrayBuffer
+ let blob = new Blob([data]);
+ return URL.createObjectURL(blob);
+ },
+
+ _sendMsg(type, data = null) {
+ dispatchEvent(
+ new CustomEvent("ContentSearchClient", {
+ detail: {
+ type,
+ data,
+ },
+ })
+ );
+ },
+};
diff --git a/browser/components/search/content/contentSearchUI.css b/browser/components/search/content/contentSearchUI.css
new file mode 100644
index 0000000000..85b718a3eb
--- /dev/null
+++ b/browser/components/search/content/contentSearchUI.css
@@ -0,0 +1,160 @@
+/* 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/. */
+
+.contentSearchSuggestionTable {
+ background-color: hsla(0,0%,100%,.99);
+ color: black;
+ border: 1px solid hsla(0, 0%, 0%, .2);
+ border-top: none;
+ box-shadow: 0 5px 10px hsla(0, 0%, 0%, .1);
+ position: absolute;
+ inset-inline-start: 0;
+ z-index: 1001;
+ user-select: none;
+ cursor: default;
+}
+
+.contentSearchSuggestionsList {
+ border-bottom: 1px solid hsl(0, 0%, 92%);
+ width: 100%;
+ height: 100%;
+}
+
+.contentSearchSuggestionTable,
+.contentSearchSuggestionsList {
+ border-spacing: 0;
+ overflow: hidden;
+ padding: 0;
+ margin: 0;
+ text-align: start;
+}
+
+.contentSearchHeaderRow,
+.contentSearchSuggestionRow {
+ margin: 0;
+ max-width: inherit;
+ padding: 0;
+}
+
+.contentSearchHeaderRow > td > img,
+.contentSearchSuggestionRow > td > .historyIcon {
+ margin-inline-end: 8px;
+ margin-bottom: -3px;
+}
+
+.contentSearchSuggestionTable .historyIcon {
+ width: 16px;
+ height: 16px;
+ display: inline-block;
+ background-image: url("chrome://browser/skin/history.svg");
+ -moz-context-properties: fill;
+ fill: graytext;
+}
+
+.contentSearchSuggestionRow.selected > td > .historyIcon {
+ fill: HighlightText;
+}
+
+.contentSearchHeader > img {
+ height: 16px;
+ width: 16px;
+ margin: 0;
+ padding: 0;
+}
+
+.contentSearchSuggestionRow.remote > td > .historyIcon {
+ visibility: hidden;
+}
+
+.contentSearchSuggestionRow.selected {
+ background-color: SelectedItem;
+ color: SelectedItemText;
+}
+
+.contentSearchHeader,
+.contentSearchSuggestionEntry {
+ margin: 0;
+ max-width: inherit;
+ overflow: hidden;
+ padding: 4px 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 75%;
+}
+
+.contentSearchHeader {
+ background-color: hsl(0, 0%, 97%);
+ color: #666;
+ border-bottom: 1px solid hsl(0, 0%, 92%);
+}
+
+.contentSearchSuggestionsContainer {
+ margin: 0;
+ padding: 0;
+ border-spacing: 0;
+ width: 100%;
+}
+
+.contentSearchSearchWithHeaderSearchText {
+ white-space: pre;
+ font-weight: bold;
+}
+
+.contentSearchOneOffItem {
+ appearance: none;
+ height: 32px;
+ margin: 0;
+ padding: 0;
+ border: none;
+ background: none;
+ background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAWCAYAAAABxvaqAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH3gofECQNNVW2/AAAABBJREFUGFdjOHPmzH8GehEA/KpKg9YTf4AAAAAASUVORK5CYII=');
+ background-repeat: no-repeat;
+ background-position: right center;
+}
+
+.contentSearchOneOffItem:dir(rtl) {
+ background-position-x: left;
+}
+
+.contentSearchOneOffItem > img {
+ width: 16px;
+ height: 16px;
+ margin-bottom: -2px;
+ pointer-events: none;
+}
+
+.contentSearchOneOffItem:not(.last-row) {
+ border-bottom: 1px solid hsl(0, 0%, 92%);
+}
+
+.contentSearchOneOffItem.end-of-row {
+ background-image: none;
+}
+
+.contentSearchOneOffItem.selected {
+ background-color: SelectedItem;
+ background-image: none;
+}
+
+.contentSearchOneOffsTable {
+ width: 100%;
+}
+
+.contentSearchSettingsButton {
+ margin: 0;
+ padding: 0;
+ height: 32px;
+ border: none;
+ border-top: 1px solid hsla(0, 0%, 0%, .08);
+ text-align: center;
+ width: 100%;
+}
+
+.contentSearchSettingsButton.selected {
+ background-color: hsl(0, 0%, 90%);
+}
+
+.contentSearchSettingsButton:active {
+ background-color: hsl(0, 0%, 85%);
+}
diff --git a/browser/components/search/content/contentSearchUI.js b/browser/components/search/content/contentSearchUI.js
new file mode 100644
index 0000000000..9c7387d364
--- /dev/null
+++ b/browser/components/search/content/contentSearchUI.js
@@ -0,0 +1,1021 @@
+/* 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/. */
+
+"use strict";
+
+this.ContentSearchUIController = (function () {
+ const MAX_DISPLAYED_SUGGESTIONS = 6;
+ const SUGGESTION_ID_PREFIX = "searchSuggestion";
+ const ONE_OFF_ID_PREFIX = "oneOff";
+ const HTML_NS = "http://www.w3.org/1999/xhtml";
+
+ /**
+ * Creates a new object that manages search suggestions and their UI for a text
+ * box.
+ *
+ * The UI consists of an html:table that's inserted into the DOM after the given
+ * text box and styled so that it appears as a dropdown below the text box.
+ *
+ * @param {DOMElement} inputElement
+ * Search suggestions will be based on the text in this text box.
+ * Assumed to be an html:input.
+ * @param {DOMElement} tableParent
+ * The suggestion table is appended as a child to this element. Since
+ * the table is absolutely positioned and its top and left values are set
+ * to be relative to the top and left of the page, either the parent and
+ * all its ancestors should not be positioned elements (i.e., their
+ * positions should be "static"), or the parent's position should be the
+ * top left of the page.
+ * @param {string} healthReportKey
+ * This will be sent with the search data for BrowserUsageTelemetry to
+ * record the search.
+ * @param {string} searchPurpose
+ * Sent with search data, see nsISearchEngine.getSubmission.
+ * @param {sring} idPrefix
+ * The IDs of elements created by the object will be prefixed with this
+ * string.
+ */
+ function ContentSearchUIController(
+ inputElement,
+ tableParent,
+ healthReportKey,
+ searchPurpose,
+ idPrefix = ""
+ ) {
+ this.input = inputElement;
+ this._idPrefix = idPrefix;
+ this._healthReportKey = healthReportKey;
+ this._searchPurpose = searchPurpose;
+ this._isPrivateEngine = false;
+
+ let tableID = idPrefix + "searchSuggestionTable";
+ this.input.autocomplete = "off";
+ this.input.setAttribute("aria-autocomplete", "true");
+ this.input.setAttribute("aria-controls", tableID);
+ tableParent.appendChild(this._makeTable(tableID));
+
+ this.input.addEventListener("keydown", this);
+ this.input.addEventListener("input", this);
+ this.input.addEventListener("focus", this);
+ this.input.addEventListener("blur", this);
+ window.addEventListener("ContentSearchService", this);
+
+ this._stickyInputValue = "";
+ this._hideSuggestions();
+
+ this._getSearchEngines();
+ this._getStrings();
+ }
+
+ ContentSearchUIController.prototype = {
+ _oneOffButtons: [],
+ // Setting up the one off buttons causes an uninterruptible reflow. If we
+ // receive the list of engines while the newtab page is loading, this reflow
+ // may regress performance - so we set this flag and only set up the buttons
+ // if it's set when the suggestions table is actually opened.
+ _pendingOneOffRefresh: undefined,
+
+ get defaultEngine() {
+ return this._defaultEngine;
+ },
+
+ set defaultEngine(engine) {
+ if (this._defaultEngine && this._defaultEngine.icon) {
+ URL.revokeObjectURL(this._defaultEngine.icon);
+ }
+ let icon;
+ if (engine.iconData) {
+ icon = this._getFaviconURIFromIconData(engine.iconData);
+ } else {
+ icon = "chrome://global/skin/icons/defaultFavicon.svg";
+ }
+ this._defaultEngine = {
+ name: engine.name,
+ icon,
+ isAppProvided: engine.isAppProvided,
+ };
+ this._updateDefaultEngineHeader();
+ this._updateDefaultEngineIcon();
+
+ if (engine && document.activeElement == this.input) {
+ this._speculativeConnect();
+ }
+ },
+
+ get engines() {
+ return this._engines;
+ },
+
+ set engines(val) {
+ this._engines = val;
+ this._pendingOneOffRefresh = true;
+ },
+
+ // The selectedIndex is the index of the element with the "selected" class in
+ // the list obtained by concatenating the suggestion rows, one-off buttons, and
+ // search settings button.
+ get selectedIndex() {
+ let allElts = [
+ ...this._suggestionsList.children,
+ ...this._oneOffButtons,
+ document.getElementById("contentSearchSettingsButton"),
+ ];
+ for (let i = 0; i < allElts.length; ++i) {
+ let elt = allElts[i];
+ if (elt.classList.contains("selected")) {
+ return i;
+ }
+ }
+ return -1;
+ },
+
+ set selectedIndex(idx) {
+ // Update the table's rows, and the input when there is a selection.
+ this._table.removeAttribute("aria-activedescendant");
+ this.input.removeAttribute("aria-activedescendant");
+
+ let allElts = [
+ ...this._suggestionsList.children,
+ ...this._oneOffButtons,
+ document.getElementById("contentSearchSettingsButton"),
+ ];
+ // If we are selecting a suggestion and a one-off is selected, don't deselect it.
+ let excludeIndex =
+ idx < this.numSuggestions && this.selectedButtonIndex > -1
+ ? this.numSuggestions + this.selectedButtonIndex
+ : -1;
+ for (let i = 0; i < allElts.length; ++i) {
+ let elt = allElts[i];
+ let ariaSelectedElt = i < this.numSuggestions ? elt.firstChild : elt;
+ if (i == idx) {
+ elt.classList.add("selected");
+ ariaSelectedElt.setAttribute("aria-selected", "true");
+ this.input.setAttribute("aria-activedescendant", ariaSelectedElt.id);
+ } else if (i != excludeIndex) {
+ elt.classList.remove("selected");
+ ariaSelectedElt.setAttribute("aria-selected", "false");
+ }
+ }
+ },
+
+ get selectedButtonIndex() {
+ let elts = [
+ ...this._oneOffButtons,
+ document.getElementById("contentSearchSettingsButton"),
+ ];
+ for (let i = 0; i < elts.length; ++i) {
+ if (elts[i].classList.contains("selected")) {
+ return i;
+ }
+ }
+ return -1;
+ },
+
+ set selectedButtonIndex(idx) {
+ let elts = [
+ ...this._oneOffButtons,
+ document.getElementById("contentSearchSettingsButton"),
+ ];
+ for (let i = 0; i < elts.length; ++i) {
+ let elt = elts[i];
+ if (i == idx) {
+ elt.classList.add("selected");
+ elt.setAttribute("aria-selected", "true");
+ } else {
+ elt.classList.remove("selected");
+ elt.setAttribute("aria-selected", "false");
+ }
+ }
+ },
+
+ get selectedEngineName() {
+ let selectedElt = this._oneOffsTable.querySelector(".selected");
+ if (selectedElt) {
+ return selectedElt.engineName;
+ }
+ return this.defaultEngine.name;
+ },
+
+ get numSuggestions() {
+ return this._suggestionsList.children.length;
+ },
+
+ selectAndUpdateInput(idx) {
+ this.selectedIndex = idx;
+ let newValue = this.suggestionAtIndex(idx) || this._stickyInputValue;
+ // Setting the input value when the value has not changed commits the current
+ // IME composition, which we don't want to do.
+ if (this.input.value != newValue) {
+ this.input.value = newValue;
+ }
+ this._updateSearchWithHeader();
+ },
+
+ suggestionAtIndex(idx) {
+ let row = this._suggestionsList.children[idx];
+ return row ? row.textContent : null;
+ },
+
+ deleteSuggestionAtIndex(idx) {
+ // Only form history suggestions can be deleted.
+ if (this.isFormHistorySuggestionAtIndex(idx)) {
+ let suggestionStr = this.suggestionAtIndex(idx);
+ this._sendMsg("RemoveFormHistoryEntry", suggestionStr);
+ this._suggestionsList.children[idx].remove();
+ this.selectAndUpdateInput(-1);
+ }
+ },
+
+ isFormHistorySuggestionAtIndex(idx) {
+ let row = this._suggestionsList.children[idx];
+ return row && row.classList.contains("formHistory");
+ },
+
+ addInputValueToFormHistory() {
+ let entry = {
+ value: this.input.value,
+ engineName: this.selectedEngineName,
+ };
+ this._sendMsg("AddFormHistoryEntry", entry);
+ return entry;
+ },
+
+ handleEvent(event) {
+ // The event handler is triggered by external events while the search
+ // element may no longer be present
+ if (!document.contains(this.input)) {
+ return;
+ }
+ this["_on" + event.type[0].toUpperCase() + event.type.substr(1)](event);
+ },
+
+ _onCommand(aEvent) {
+ if (this.selectedButtonIndex == this._oneOffButtons.length) {
+ // Settings button was selected.
+ this._sendMsg("ManageEngines");
+ return;
+ }
+
+ this.search(aEvent);
+
+ if (aEvent) {
+ aEvent.preventDefault();
+ }
+ },
+
+ search(aEvent) {
+ if (!this.defaultEngine) {
+ return; // Not initialized yet.
+ }
+
+ let searchText = this.input;
+ let searchTerms;
+ if (
+ this._table.hidden ||
+ (aEvent.originalTarget &&
+ aEvent.originalTarget.id == "contentSearchDefaultEngineHeader") ||
+ aEvent instanceof KeyboardEvent
+ ) {
+ searchTerms = searchText.value;
+ } else {
+ searchTerms =
+ this.suggestionAtIndex(this.selectedIndex) || searchText.value;
+ }
+ // Send an event that will perform a search and Firefox Health Report will
+ // record that a search from the healthReportKey passed to the constructor.
+ let eventData = {
+ engineName: this.selectedEngineName,
+ searchString: searchTerms,
+ healthReportKey: this._healthReportKey,
+ searchPurpose: this._searchPurpose,
+ originalEvent: {
+ shiftKey: aEvent.shiftKey,
+ ctrlKey: aEvent.ctrlKey,
+ metaKey: aEvent.metaKey,
+ altKey: aEvent.altKey,
+ },
+ };
+ if ("button" in aEvent) {
+ eventData.originalEvent.button = aEvent.button;
+ }
+
+ if (this.suggestionAtIndex(this.selectedIndex)) {
+ eventData.selection = {
+ index: this.selectedIndex,
+ kind: undefined,
+ };
+ if (aEvent instanceof MouseEvent) {
+ eventData.selection.kind = "mouse";
+ } else if (aEvent instanceof KeyboardEvent) {
+ eventData.selection.kind = "key";
+ }
+ }
+
+ this._sendMsg("Search", eventData);
+ this.addInputValueToFormHistory();
+ },
+
+ _onInput() {
+ if (!this.input.value) {
+ this._stickyInputValue = "";
+ this._hideSuggestions();
+ } else if (this.input.value != this._stickyInputValue) {
+ // Only fetch new suggestions if the input value has changed.
+ this._getSuggestions();
+ this.selectAndUpdateInput(-1);
+ }
+ this._updateSearchWithHeader();
+ },
+
+ _onKeydown(event) {
+ let selectedIndexDelta = 0;
+ let selectedSuggestionDelta = 0;
+ let selectedOneOffDelta = 0;
+
+ switch (event.keyCode) {
+ case event.DOM_VK_UP:
+ if (this._table.hidden) {
+ return;
+ }
+ if (event.getModifierState("Accel")) {
+ if (event.shiftKey) {
+ selectedSuggestionDelta = -1;
+ break;
+ }
+ this._cycleCurrentEngine(true);
+ break;
+ }
+ if (event.altKey) {
+ selectedOneOffDelta = -1;
+ break;
+ }
+ selectedIndexDelta = -1;
+ break;
+ case event.DOM_VK_DOWN:
+ if (this._table.hidden) {
+ this._getSuggestions();
+ return;
+ }
+ if (event.getModifierState("Accel")) {
+ if (event.shiftKey) {
+ selectedSuggestionDelta = 1;
+ break;
+ }
+ this._cycleCurrentEngine(false);
+ break;
+ }
+ if (event.altKey) {
+ selectedOneOffDelta = 1;
+ break;
+ }
+ selectedIndexDelta = 1;
+ break;
+ case event.DOM_VK_TAB:
+ if (this._table.hidden) {
+ return;
+ }
+ // Shift+tab when either the first or no one-off is selected, as well as
+ // tab when the settings button is selected, should change focus as normal.
+ if (
+ (this.selectedButtonIndex <= 0 && event.shiftKey) ||
+ (this.selectedButtonIndex == this._oneOffButtons.length &&
+ !event.shiftKey)
+ ) {
+ return;
+ }
+ selectedOneOffDelta = event.shiftKey ? -1 : 1;
+ break;
+ case event.DOM_VK_RIGHT:
+ // Allow normal caret movement until the caret is at the end of the input.
+ if (
+ this.input.selectionStart != this.input.selectionEnd ||
+ this.input.selectionEnd != this.input.value.length
+ ) {
+ return;
+ }
+ if (
+ this.numSuggestions &&
+ this.selectedIndex >= 0 &&
+ this.selectedIndex < this.numSuggestions
+ ) {
+ this.input.value = this.suggestionAtIndex(this.selectedIndex);
+ this.input.setAttribute("selection-index", this.selectedIndex);
+ this.input.setAttribute("selection-kind", "key");
+ } else {
+ // If we didn't select anything, make sure to remove the attributes
+ // in case they were populated last time.
+ this.input.removeAttribute("selection-index");
+ this.input.removeAttribute("selection-kind");
+ }
+ this._stickyInputValue = this.input.value;
+ this._hideSuggestions();
+ return;
+ case event.DOM_VK_RETURN:
+ this._onCommand(event);
+ return;
+ case event.DOM_VK_DELETE:
+ if (this.selectedIndex >= 0) {
+ this.deleteSuggestionAtIndex(this.selectedIndex);
+ }
+ return;
+ case event.DOM_VK_ESCAPE:
+ if (!this._table.hidden) {
+ this._hideSuggestions();
+ }
+ return;
+ default:
+ return;
+ }
+
+ let currentIndex = this.selectedIndex;
+ if (selectedIndexDelta) {
+ let newSelectedIndex = currentIndex + selectedIndexDelta;
+ if (newSelectedIndex < -1) {
+ newSelectedIndex = this.numSuggestions + this._oneOffButtons.length;
+ }
+ // If are moving up from the first one off, we have to deselect the one off
+ // manually because the selectedIndex setter tries to exclude the selected
+ // one-off (which is desirable for accel+shift+up/down).
+ if (currentIndex == this.numSuggestions && selectedIndexDelta == -1) {
+ this.selectedButtonIndex = -1;
+ }
+ this.selectAndUpdateInput(newSelectedIndex);
+ } else if (selectedSuggestionDelta) {
+ let newSelectedIndex;
+ if (currentIndex >= this.numSuggestions || currentIndex == -1) {
+ // No suggestion already selected, select the first/last one appropriately.
+ newSelectedIndex =
+ selectedSuggestionDelta == 1 ? 0 : this.numSuggestions - 1;
+ } else {
+ newSelectedIndex = currentIndex + selectedSuggestionDelta;
+ }
+ if (newSelectedIndex >= this.numSuggestions) {
+ newSelectedIndex = -1;
+ }
+ this.selectAndUpdateInput(newSelectedIndex);
+ } else if (selectedOneOffDelta) {
+ let newSelectedIndex;
+ let currentButton = this.selectedButtonIndex;
+ if (
+ currentButton == -1 ||
+ currentButton == this._oneOffButtons.length
+ ) {
+ // No one-off already selected, select the first/last one appropriately.
+ newSelectedIndex =
+ selectedOneOffDelta == 1 ? 0 : this._oneOffButtons.length - 1;
+ } else {
+ newSelectedIndex = currentButton + selectedOneOffDelta;
+ }
+ // Allow selection of the settings button via the tab key.
+ if (
+ newSelectedIndex == this._oneOffButtons.length &&
+ event.keyCode != event.DOM_VK_TAB
+ ) {
+ newSelectedIndex = -1;
+ }
+ this.selectedButtonIndex = newSelectedIndex;
+ }
+
+ // Prevent the input's caret from moving.
+ event.preventDefault();
+ },
+
+ _currentEngineIndex: -1,
+ _cycleCurrentEngine(aReverse) {
+ if (
+ (this._currentEngineIndex == this._engines.length - 1 && !aReverse) ||
+ (this._currentEngineIndex == 0 && aReverse)
+ ) {
+ return;
+ }
+ this._currentEngineIndex += aReverse ? -1 : 1;
+ let engineName = this._engines[this._currentEngineIndex].name;
+ this._sendMsg("SetCurrentEngine", engineName);
+ },
+
+ _onFocus() {
+ if (this._mousedown) {
+ return;
+ }
+ // When the input box loses focus to something in our table, we refocus it
+ // immediately. This causes the focus highlight to flicker, so we set a
+ // custom attribute which consumers should use for focus highlighting. This
+ // attribute is removed only when we do not immediately refocus the input
+ // box, thus eliminating flicker.
+ this.input.setAttribute("keepfocus", "true");
+ this._speculativeConnect();
+ },
+
+ _onBlur() {
+ if (this._mousedown) {
+ // At this point, this.input has lost focus, but a new element has not yet
+ // received it. If we re-focus this.input directly, the new element will
+ // steal focus immediately, so we queue it instead.
+ setTimeout(() => this.input.focus(), 0);
+ return;
+ }
+ this.input.removeAttribute("keepfocus");
+ this._hideSuggestions();
+ },
+
+ _onMousemove(event) {
+ let idx = this._indexOfTableItem(event.target);
+ if (idx >= this.numSuggestions) {
+ // Deselect any search suggestion that has been selected.
+ this.selectedIndex = -1;
+ this.selectedButtonIndex = idx - this.numSuggestions;
+ return;
+ }
+ this.selectedIndex = idx;
+ },
+
+ _onMouseup(event) {
+ if (event.button == 2) {
+ return;
+ }
+ this._onCommand(event);
+ },
+
+ _onMouseout(event) {
+ // We only deselect one-off buttons and the settings button when they are
+ // moused out.
+ let idx = this._indexOfTableItem(event.originalTarget);
+ if (idx >= this.numSuggestions) {
+ this.selectedButtonIndex = -1;
+ }
+ },
+
+ _onClick(event) {
+ this._onMouseup(event);
+ },
+
+ _onContentSearchService(event) {
+ let methodName = "_onMsg" + event.detail.type;
+ if (methodName in this) {
+ this[methodName](event.detail.data);
+ }
+ },
+
+ _onMsgFocusInput(event) {
+ this.input.focus();
+ },
+
+ _onMsgBlur(event) {
+ this.input.blur();
+ this._hideSuggestions();
+ },
+
+ _onMsgSuggestions(suggestions) {
+ // Ignore the suggestions if their search string or engine doesn't match
+ // ours. Due to the async nature of message passing, this can easily happen
+ // when the user types quickly.
+ if (
+ this._stickyInputValue != suggestions.searchString ||
+ this.defaultEngine.name != suggestions.engineName
+ ) {
+ return;
+ }
+
+ this._clearSuggestionRows();
+
+ // Position and size the table.
+ let { left } = this.input.getBoundingClientRect();
+ this._table.style.top = this.input.offsetHeight + "px";
+ this._table.style.minWidth = this.input.offsetWidth + "px";
+ this._table.style.maxWidth = window.innerWidth - left - 40 + "px";
+
+ // Add the suggestions to the table.
+ let searchWords = new Set(
+ suggestions.searchString.trim().toLowerCase().split(/\s+/)
+ );
+ for (let i = 0; i < MAX_DISPLAYED_SUGGESTIONS; i++) {
+ let type, idx;
+ if (i < suggestions.formHistory.length) {
+ [type, idx] = ["formHistory", i];
+ } else {
+ let j = i - suggestions.formHistory.length;
+ if (j < suggestions.remote.length) {
+ [type, idx] = ["remote", j];
+ } else {
+ break;
+ }
+ }
+ this._suggestionsList.appendChild(
+ this._makeTableRow(type, suggestions[type][idx], i, searchWords)
+ );
+ }
+
+ if (this._table.hidden) {
+ this.selectedIndex = -1;
+ if (this._pendingOneOffRefresh) {
+ this._setUpOneOffButtons();
+ delete this._pendingOneOffRefresh;
+ }
+ this._currentEngineIndex = this._engines.findIndex(
+ aEngine => aEngine.name == this.defaultEngine.name
+ );
+ this._table.hidden = false;
+ this.input.setAttribute("aria-expanded", "true");
+ }
+ },
+
+ _onMsgSuggestionsCancelled() {
+ if (!this._table.hidden) {
+ this._hideSuggestions();
+ }
+ },
+
+ _onMsgState(state) {
+ // Not all state messages broadcast the windows' privateness info.
+ if ("isPrivateWindow" in state) {
+ this._isPrivateEngine = state.isPrivateEngine;
+ }
+
+ this.engines = state.engines;
+
+ let currentEngine = state.currentEngine;
+ if (this._isPrivateEngine) {
+ currentEngine = state.currentPrivateEngine;
+ }
+
+ // No point updating the default engine (and the header) if there's no change.
+ if (
+ this.defaultEngine &&
+ this.defaultEngine.name == currentEngine.name &&
+ this.defaultEngine.icon == currentEngine.icon
+ ) {
+ return;
+ }
+ this.defaultEngine = currentEngine;
+ },
+
+ _onMsgCurrentState(state) {
+ this._onMsgState(state);
+ },
+
+ _onMsgCurrentEngine(engine) {
+ if (this._isPrivateEngine) {
+ return;
+ }
+ this.defaultEngine = engine;
+ this._pendingOneOffRefresh = true;
+ },
+
+ _onMsgCurrentPrivateEngine(engine) {
+ if (!this._isPrivateEngine) {
+ return;
+ }
+ this.defaultEngine = engine;
+ this._pendingOneOffRefresh = true;
+ },
+
+ _onMsgStrings(strings) {
+ this._strings = strings;
+ this._updateDefaultEngineHeader();
+ this._updateSearchWithHeader();
+ document.getElementById("contentSearchSettingsButton").textContent =
+ this._strings.searchSettings;
+ },
+
+ _updateDefaultEngineIcon() {
+ // We only show the engine's own icon for app provided engines, otherwise show
+ // a default. xref https://bugzilla.mozilla.org/show_bug.cgi?id=1449338#c19
+ let icon = this.defaultEngine.isAppProvided
+ ? this.defaultEngine.icon
+ : "chrome://global/skin/icons/search-glass.svg";
+
+ document.body.style.setProperty(
+ "--newtab-search-icon",
+ "url(" + icon + ")"
+ );
+ },
+
+ _updateDefaultEngineHeader() {
+ let header = document.getElementById("contentSearchDefaultEngineHeader");
+ header.firstChild.setAttribute("src", this.defaultEngine.icon);
+ if (!this._strings) {
+ return;
+ }
+ while (header.firstChild.nextSibling) {
+ header.firstChild.nextSibling.remove();
+ }
+ header.appendChild(
+ document.createTextNode(
+ this._strings.searchHeader.replace("%S", this.defaultEngine.name)
+ )
+ );
+ },
+
+ _updateSearchWithHeader() {
+ if (!this._strings) {
+ return;
+ }
+ let searchWithHeader = document.getElementById(
+ "contentSearchSearchWithHeader"
+ );
+ let labels = searchWithHeader.querySelectorAll("label");
+ if (this.input.value) {
+ let header = this._strings.searchForSomethingWith2;
+ // Translators can use both %S and %1$S.
+ header = header.replace("%1$S", "%S").split("%S");
+ labels[0].textContent = header[0];
+ labels[1].textContent = this.input.value;
+ labels[2].textContent = header[1];
+ } else {
+ labels[0].textContent = this._strings.searchWithHeader;
+ labels[1].textContent = "";
+ labels[2].textContent = "";
+ }
+ },
+
+ _speculativeConnect() {
+ if (this.defaultEngine) {
+ this._sendMsg("SpeculativeConnect", this.defaultEngine.name);
+ }
+ },
+
+ _makeTableRow(type, suggestionStr, currentRow, searchWords) {
+ let row = document.createElementNS(HTML_NS, "tr");
+ row.dir = "auto";
+ row.classList.add("contentSearchSuggestionRow");
+ row.classList.add(type);
+ row.setAttribute("role", "presentation");
+ row.addEventListener("mousemove", this);
+ row.addEventListener("mouseup", this);
+
+ let entry = document.createElementNS(HTML_NS, "td");
+ let img = document.createElementNS(HTML_NS, "div");
+ img.setAttribute("class", "historyIcon");
+ entry.appendChild(img);
+ entry.classList.add("contentSearchSuggestionEntry");
+ entry.setAttribute("role", "option");
+ entry.id = this._idPrefix + SUGGESTION_ID_PREFIX + currentRow;
+ entry.setAttribute("aria-selected", "false");
+
+ let suggestionWords = suggestionStr.trim().toLowerCase().split(/\s+/);
+ for (let i = 0; i < suggestionWords.length; i++) {
+ let word = suggestionWords[i];
+ let wordSpan = document.createElementNS(HTML_NS, "span");
+ if (searchWords.has(word)) {
+ wordSpan.classList.add("typed");
+ }
+ wordSpan.textContent = word;
+ entry.appendChild(wordSpan);
+ if (i < suggestionWords.length - 1) {
+ entry.appendChild(document.createTextNode(" "));
+ }
+ }
+
+ row.appendChild(entry);
+ return row;
+ },
+
+ // If the favicon is an array buffer, convert it into a Blob URI.
+ // Otherwise just return the plain URI.
+ _getFaviconURIFromIconData(data) {
+ if (typeof data == "string") {
+ return data;
+ }
+
+ // If typeof(data) != "string", we assume it's an ArrayBuffer
+ let blob = new Blob([data]);
+ return URL.createObjectURL(blob);
+ },
+
+ // Adds "@2x" to the name of the given PNG url for "retina" screens.
+ _getImageURIForCurrentResolution(uri) {
+ if (window.devicePixelRatio > 1) {
+ return uri.replace(/\.png$/, "@2x.png");
+ }
+ return uri;
+ },
+
+ _getSearchEngines() {
+ this._sendMsg("GetState");
+ },
+
+ _getStrings() {
+ this._sendMsg("GetStrings");
+ },
+
+ _getSuggestions() {
+ this._stickyInputValue = this.input.value;
+ if (this.defaultEngine) {
+ this._sendMsg("GetSuggestions", {
+ engineName: this.defaultEngine.name,
+ searchString: this.input.value,
+ });
+ }
+ },
+
+ _clearSuggestionRows() {
+ while (this._suggestionsList.firstElementChild) {
+ this._suggestionsList.firstElementChild.remove();
+ }
+ },
+
+ _hideSuggestions() {
+ this.input.setAttribute("aria-expanded", "false");
+ this.selectedIndex = -1;
+ this.selectedButtonIndex = -1;
+ this._currentEngineIndex = -1;
+ this._table.hidden = true;
+ },
+
+ _indexOfTableItem(elt) {
+ if (elt.classList.contains("contentSearchOneOffItem")) {
+ return this.numSuggestions + this._oneOffButtons.indexOf(elt);
+ }
+ if (elt.classList.contains("contentSearchSettingsButton")) {
+ return this.numSuggestions + this._oneOffButtons.length;
+ }
+ while (elt && elt.localName != "tr") {
+ elt = elt.parentNode;
+ }
+ if (!elt) {
+ throw new Error("Element is not a row");
+ }
+ return elt.rowIndex;
+ },
+
+ _makeTable(id) {
+ this._table = document.createElementNS(HTML_NS, "table");
+ this._table.id = id;
+ this._table.hidden = true;
+ this._table.classList.add("contentSearchSuggestionTable");
+ this._table.setAttribute("role", "presentation");
+
+ // When the search input box loses focus, we want to immediately give focus
+ // back to it if the blur was because the user clicked somewhere in the table.
+ // onBlur uses the _mousedown flag to detect this.
+ this._table.addEventListener("mousedown", () => {
+ this._mousedown = true;
+ });
+ document.addEventListener("mouseup", () => {
+ delete this._mousedown;
+ });
+
+ // Deselect the selected element on mouseout if it wasn't a suggestion.
+ this._table.addEventListener("mouseout", this);
+
+ let headerRow = document.createElementNS(HTML_NS, "tr");
+ let header = document.createElementNS(HTML_NS, "td");
+ headerRow.setAttribute("class", "contentSearchHeaderRow");
+ header.setAttribute("class", "contentSearchHeader");
+ let iconImg = document.createElementNS(HTML_NS, "img");
+ header.appendChild(iconImg);
+ header.id = "contentSearchDefaultEngineHeader";
+ headerRow.appendChild(header);
+ headerRow.addEventListener("click", this);
+ this._table.appendChild(headerRow);
+
+ let row = document.createElementNS(HTML_NS, "tr");
+ row.setAttribute("class", "contentSearchSuggestionsContainer");
+ let cell = document.createElementNS(HTML_NS, "td");
+ cell.setAttribute("class", "contentSearchSuggestionsContainer");
+ this._suggestionsList = document.createElementNS(HTML_NS, "table");
+ this._suggestionsList.setAttribute(
+ "class",
+ "contentSearchSuggestionsList"
+ );
+ cell.appendChild(this._suggestionsList);
+ row.appendChild(cell);
+ this._table.appendChild(row);
+ this._suggestionsList.setAttribute("role", "listbox");
+
+ this._oneOffsTable = document.createElementNS(HTML_NS, "table");
+ this._oneOffsTable.setAttribute("class", "contentSearchOneOffsTable");
+ this._oneOffsTable.classList.add("contentSearchSuggestionsContainer");
+ this._oneOffsTable.setAttribute("role", "group");
+ this._table.appendChild(this._oneOffsTable);
+
+ headerRow = document.createElementNS(HTML_NS, "tr");
+ header = document.createElementNS(HTML_NS, "td");
+ headerRow.setAttribute("class", "contentSearchHeaderRow");
+ header.setAttribute("class", "contentSearchHeader");
+ headerRow.appendChild(header);
+ header.id = "contentSearchSearchWithHeader";
+ let start = document.createElement("label");
+ let inputLabel = document.createElement("label");
+ inputLabel.setAttribute(
+ "class",
+ "contentSearchSearchWithHeaderSearchText"
+ );
+ let end = document.createElement("label");
+ header.appendChild(start);
+ header.appendChild(inputLabel);
+ header.appendChild(end);
+ this._oneOffsTable.appendChild(headerRow);
+
+ let button = document.createElementNS(HTML_NS, "button");
+ button.setAttribute("class", "contentSearchSettingsButton");
+ button.classList.add("contentSearchHeaderRow");
+ button.classList.add("contentSearchHeader");
+ button.id = "contentSearchSettingsButton";
+ button.addEventListener("click", this);
+ button.addEventListener("mousemove", this);
+ this._table.appendChild(button);
+
+ return this._table;
+ },
+
+ _setUpOneOffButtons() {
+ // Sometimes we receive a CurrentEngine message from the ContentSearch service
+ // before we've received a State message - i.e. before we have our engines.
+ if (!this._engines) {
+ return;
+ }
+
+ while (this._oneOffsTable.firstChild.nextSibling) {
+ this._oneOffsTable.firstChild.nextSibling.remove();
+ }
+
+ this._oneOffButtons = [];
+
+ let engines = this._engines
+ .filter(aEngine => aEngine.name != this.defaultEngine.name)
+ .filter(aEngine => !aEngine.hidden);
+ if (!engines.length) {
+ this._oneOffsTable.hidden = true;
+ return;
+ }
+
+ const kDefaultButtonWidth = 49; // 48px + 1px border.
+ let rowWidth = this.input.offsetWidth - 2; // 2px border.
+ let enginesPerRow = Math.floor(rowWidth / kDefaultButtonWidth);
+ let buttonWidth = Math.floor(rowWidth / enginesPerRow);
+
+ let row = document.createElementNS(HTML_NS, "tr");
+ let cell = document.createElementNS(HTML_NS, "td");
+ row.setAttribute("class", "contentSearchSuggestionsContainer");
+ cell.setAttribute("class", "contentSearchSuggestionsContainer");
+
+ for (let i = 0; i < engines.length; ++i) {
+ let engine = engines[i];
+ if (i > 0 && i % enginesPerRow == 0) {
+ row.appendChild(cell);
+ this._oneOffsTable.appendChild(row);
+ row = document.createElementNS(HTML_NS, "tr");
+ cell = document.createElementNS(HTML_NS, "td");
+ row.setAttribute("class", "contentSearchSuggestionsContainer");
+ cell.setAttribute("class", "contentSearchSuggestionsContainer");
+ }
+ let button = document.createElementNS(HTML_NS, "button");
+ button.setAttribute("class", "contentSearchOneOffItem");
+ let img = document.createElementNS(HTML_NS, "img");
+ let uri;
+ if (engine.iconData) {
+ uri = this._getFaviconURIFromIconData(engine.iconData);
+ } else {
+ uri = this._getImageURIForCurrentResolution(
+ "chrome://browser/skin/search-engine-placeholder.png"
+ );
+ }
+ img.setAttribute("src", uri);
+ img.addEventListener(
+ "load",
+ function () {
+ URL.revokeObjectURL(uri);
+ },
+ { once: true }
+ );
+ button.appendChild(img);
+ button.style.width = buttonWidth + "px";
+ button.setAttribute("engine-name", engine.name);
+
+ button.engineName = engine.name;
+ button.addEventListener("click", this);
+ button.addEventListener("mousemove", this);
+
+ if (engines.length - i <= enginesPerRow - (i % enginesPerRow)) {
+ button.classList.add("last-row");
+ }
+
+ if ((i + 1) % enginesPerRow == 0) {
+ button.classList.add("end-of-row");
+ }
+
+ button.id = ONE_OFF_ID_PREFIX + i;
+ cell.appendChild(button);
+ this._oneOffButtons.push(button);
+ }
+ row.appendChild(cell);
+ this._oneOffsTable.appendChild(row);
+ this._oneOffsTable.hidden = false;
+ },
+
+ _sendMsg(type, data = null) {
+ dispatchEvent(
+ new CustomEvent("ContentSearchClient", {
+ detail: {
+ type,
+ data,
+ },
+ })
+ );
+ },
+ };
+
+ return ContentSearchUIController;
+})();
diff --git a/browser/components/search/content/searchbar.js b/browser/components/search/content/searchbar.js
new file mode 100644
index 0000000000..e8b2dd5045
--- /dev/null
+++ b/browser/components/search/content/searchbar.js
@@ -0,0 +1,895 @@
+/* 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/. */
+
+"use strict";
+
+/* globals XULCommandEvent */
+
+// This is loaded into chrome windows with the subscript loader. Wrap in
+// a block to prevent accidentally leaking globals onto `window`.
+{
+ const lazy = {};
+
+ ChromeUtils.defineESModuleGetters(lazy, {
+ FormHistory: "resource://gre/modules/FormHistory.sys.mjs",
+ SearchSuggestionController:
+ "resource://gre/modules/SearchSuggestionController.sys.mjs",
+ });
+
+ /**
+ * Defines the search bar element.
+ */
+ class MozSearchbar extends MozXULElement {
+ static get inheritedAttributes() {
+ return {
+ ".searchbar-textbox":
+ "disabled,disableautocomplete,searchengine,src,newlines",
+ ".searchbar-search-button": "addengines",
+ };
+ }
+
+ static get markup() {
+ return `
+ <stringbundle src="chrome://browser/locale/search.properties"></stringbundle>
+ <hbox class="searchbar-search-button" data-l10n-id="searchbar-icon">
+ <image class="searchbar-search-icon"></image>
+ <image class="searchbar-search-icon-overlay"></image>
+ </hbox>
+ <html:input class="searchbar-textbox" is="autocomplete-input" type="search" data-l10n-id="searchbar-input" autocompletepopup="PopupSearchAutoComplete" autocompletesearch="search-autocomplete" autocompletesearchparam="searchbar-history" maxrows="10" completeselectedindex="true" minresultsforpopup="0"/>
+ <menupopup class="textbox-contextmenu"></menupopup>
+ <hbox class="search-go-container" align="center">
+ <image class="search-go-button urlbar-icon" hidden="true" onclick="handleSearchCommand(event);" data-l10n-id="searchbar-submit"></image>
+ </hbox>
+ `;
+ }
+
+ constructor() {
+ super();
+
+ MozXULElement.insertFTLIfNeeded("browser/search.ftl");
+
+ this.destroy = this.destroy.bind(this);
+ this._setupEventListeners();
+ let searchbar = this;
+ this.observer = {
+ observe(aEngine, aTopic, aVerb) {
+ if (
+ aTopic == "browser-search-engine-modified" ||
+ (aTopic == "browser-search-service" && aVerb == "init-complete")
+ ) {
+ // Make sure the engine list is refetched next time it's needed
+ searchbar._engines = null;
+
+ // Update the popup header and update the display after any modification.
+ searchbar._textbox.popup.updateHeader();
+ searchbar.updateDisplay();
+ }
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+ };
+
+ this._ignoreFocus = false;
+ this._engines = null;
+ this.telemetrySelectedIndex = -1;
+ }
+
+ connectedCallback() {
+ // Don't initialize if this isn't going to be visible
+ if (this.closest("#BrowserToolbarPalette")) {
+ return;
+ }
+
+ this.appendChild(this.constructor.fragment);
+ this.initializeAttributeInheritance();
+
+ // Don't go further if in Customize mode.
+ if (this.parentNode.parentNode.localName == "toolbarpaletteitem") {
+ return;
+ }
+
+ // Ensure we get persisted widths back, if we've been in the palette:
+ let storedWidth = Services.xulStore.getValue(
+ document.documentURI,
+ this.parentNode.id,
+ "width"
+ );
+ if (storedWidth) {
+ this.parentNode.setAttribute("width", storedWidth);
+ this.parentNode.style.width = storedWidth + "px";
+ }
+
+ this._stringBundle = this.querySelector("stringbundle");
+ this._textbox = this.querySelector(".searchbar-textbox");
+
+ this._menupopup = null;
+ this._pasteAndSearchMenuItem = null;
+
+ this._setupTextboxEventListeners();
+ this._initTextbox();
+
+ window.addEventListener("unload", this.destroy);
+
+ Services.obs.addObserver(this.observer, "browser-search-engine-modified");
+ Services.obs.addObserver(this.observer, "browser-search-service");
+
+ this._initialized = true;
+
+ (window.delayedStartupPromise || Promise.resolve()).then(() => {
+ window.requestIdleCallback(() => {
+ Services.search
+ .init()
+ .then(aStatus => {
+ // Bail out if the binding's been destroyed
+ if (!this._initialized) {
+ return;
+ }
+
+ // Refresh the display (updating icon, etc)
+ this.updateDisplay();
+ BrowserSearch.updateOpenSearchBadge();
+ })
+ .catch(status =>
+ console.error(
+ "Cannot initialize search service, bailing out: " + status
+ )
+ );
+ });
+ });
+
+ // Wait until the popupshowing event to avoid forcing immediate
+ // attachment of the search-one-offs binding.
+ this.textbox.popup.addEventListener(
+ "popupshowing",
+ () => {
+ let oneOffButtons = this.textbox.popup.oneOffButtons;
+ // Some accessibility tests create their own <searchbar> that doesn't
+ // use the popup binding below, so null-check oneOffButtons.
+ if (oneOffButtons) {
+ oneOffButtons.telemetryOrigin = "searchbar";
+ // Set .textbox first, since the popup setter will cause
+ // a _rebuild call that uses it.
+ oneOffButtons.textbox = this.textbox;
+ oneOffButtons.popup = this.textbox.popup;
+ }
+ },
+ { capture: true, once: true }
+ );
+ }
+
+ async getEngines() {
+ if (!this._engines) {
+ this._engines = await Services.search.getVisibleEngines();
+ }
+ return this._engines;
+ }
+
+ set currentEngine(val) {
+ if (PrivateBrowsingUtils.isWindowPrivate(window)) {
+ Services.search.setDefaultPrivate(
+ val,
+ Ci.nsISearchService.CHANGE_REASON_USER_SEARCHBAR
+ );
+ } else {
+ Services.search.setDefault(
+ val,
+ Ci.nsISearchService.CHANGE_REASON_USER_SEARCHBAR
+ );
+ }
+ }
+
+ get currentEngine() {
+ let currentEngine;
+ if (PrivateBrowsingUtils.isWindowPrivate(window)) {
+ currentEngine = Services.search.defaultPrivateEngine;
+ } else {
+ currentEngine = Services.search.defaultEngine;
+ }
+ // Return a dummy engine if there is no currentEngine
+ return currentEngine || { name: "", uri: null };
+ }
+
+ /**
+ * textbox is used by sanitize.js to clear the undo history when
+ * clearing form information.
+ *
+ * @returns {HTMLInputElement}
+ */
+ get textbox() {
+ return this._textbox;
+ }
+
+ set value(val) {
+ this._textbox.value = val;
+ }
+
+ get value() {
+ return this._textbox.value;
+ }
+
+ destroy() {
+ if (this._initialized) {
+ this._initialized = false;
+ window.removeEventListener("unload", this.destroy);
+
+ Services.obs.removeObserver(
+ this.observer,
+ "browser-search-engine-modified"
+ );
+ Services.obs.removeObserver(this.observer, "browser-search-service");
+ }
+
+ // Make sure to break the cycle from _textbox to us. Otherwise we leak
+ // the world. But make sure it's actually pointing to us.
+ // Also make sure the textbox has ever been constructed, otherwise the
+ // _textbox getter will cause the textbox constructor to run, add an
+ // observer, and leak the world too.
+ if (
+ this._textbox &&
+ this._textbox.mController &&
+ this._textbox.mController.input &&
+ this._textbox.mController.input.wrappedJSObject ==
+ this.nsIAutocompleteInput
+ ) {
+ this._textbox.mController.input = null;
+ }
+ }
+
+ focus() {
+ this._textbox.focus();
+ }
+
+ select() {
+ this._textbox.select();
+ }
+
+ setIcon(element, uri) {
+ element.setAttribute("src", uri);
+ }
+
+ updateDisplay() {
+ this._textbox.title = this._stringBundle.getFormattedString("searchtip", [
+ this.currentEngine.name,
+ ]);
+ }
+
+ updateGoButtonVisibility() {
+ this.querySelector(".search-go-button").hidden = !this._textbox.value;
+ }
+
+ openSuggestionsPanel(aShowOnlySettingsIfEmpty) {
+ if (this._textbox.open) {
+ return;
+ }
+
+ this._textbox.showHistoryPopup();
+
+ if (this._textbox.value) {
+ // showHistoryPopup does a startSearch("") call, ensure the
+ // controller handles the text from the input box instead:
+ this._textbox.mController.handleText();
+ } else if (aShowOnlySettingsIfEmpty) {
+ this.setAttribute("showonlysettings", "true");
+ }
+ }
+
+ async selectEngine(aEvent, isNextEngine) {
+ // Stop event bubbling now, because the rest of this method is async.
+ aEvent.preventDefault();
+ aEvent.stopPropagation();
+
+ // Find the new index.
+ let engines = await this.getEngines();
+ let currentName = this.currentEngine.name;
+ let newIndex = -1;
+ let lastIndex = engines.length - 1;
+ for (let i = lastIndex; i >= 0; --i) {
+ if (engines[i].name == currentName) {
+ // Check bounds to cycle through the list of engines continuously.
+ if (!isNextEngine && i == 0) {
+ newIndex = lastIndex;
+ } else if (isNextEngine && i == lastIndex) {
+ newIndex = 0;
+ } else {
+ newIndex = i + (isNextEngine ? 1 : -1);
+ }
+ break;
+ }
+ }
+
+ this.currentEngine = engines[newIndex];
+
+ this.openSuggestionsPanel();
+ }
+
+ handleSearchCommand(aEvent, aEngine, aForceNewTab) {
+ let where = "current";
+ let params;
+ const newTabPref = Services.prefs.getBoolPref("browser.search.openintab");
+
+ // Open ctrl/cmd clicks on one-off buttons in a new background tab.
+ if (
+ aEvent &&
+ aEvent.originalTarget.classList.contains("search-go-button")
+ ) {
+ if (aEvent.button == 2) {
+ return;
+ }
+ where = whereToOpenLink(aEvent, false, true);
+ if (
+ newTabPref &&
+ !aEvent.altKey &&
+ !aEvent.getModifierState("AltGraph") &&
+ where == "current" &&
+ !gBrowser.selectedTab.isEmpty
+ ) {
+ where = "tab";
+ }
+ } else if (aForceNewTab) {
+ where = "tab";
+ if (Services.prefs.getBoolPref("browser.tabs.loadInBackground")) {
+ where += "-background";
+ }
+ } else {
+ if (
+ (KeyboardEvent.isInstance(aEvent) &&
+ (aEvent.altKey || aEvent.getModifierState("AltGraph"))) ^
+ newTabPref &&
+ !gBrowser.selectedTab.isEmpty
+ ) {
+ where = "tab";
+ }
+ if (
+ MouseEvent.isInstance(aEvent) &&
+ (aEvent.button == 1 || aEvent.getModifierState("Accel"))
+ ) {
+ where = "tab";
+ params = {
+ inBackground: true,
+ };
+ }
+ }
+ this.handleSearchCommandWhere(aEvent, aEngine, where, params);
+ }
+
+ handleSearchCommandWhere(aEvent, aEngine, aWhere, aParams = {}) {
+ let textBox = this._textbox;
+ let textValue = textBox.value;
+
+ let selectedIndex = this.telemetrySelectedIndex;
+ let isOneOff = false;
+
+ BrowserSearchTelemetry.recordSearchSuggestionSelectionMethod(
+ aEvent,
+ "searchbar",
+ selectedIndex
+ );
+
+ if (selectedIndex == -1) {
+ isOneOff =
+ this.textbox.popup.oneOffButtons.eventTargetIsAOneOff(aEvent);
+ }
+
+ if (aWhere === "tab" && !!aParams.inBackground) {
+ // Keep the focus in the search bar.
+ aParams.avoidBrowserFocus = true;
+ } else if (
+ aWhere !== "window" &&
+ aEvent.keyCode === KeyEvent.DOM_VK_RETURN
+ ) {
+ // Move the focus to the selected browser when keyup the Enter.
+ aParams.avoidBrowserFocus = true;
+ this._needBrowserFocusAtEnterKeyUp = true;
+ }
+
+ // This is a one-off search only if oneOffRecorded is true.
+ this.doSearch(textValue, aWhere, aEngine, aParams, isOneOff);
+ }
+
+ doSearch(aData, aWhere, aEngine, aParams, isOneOff = false) {
+ let textBox = this._textbox;
+ let engine = aEngine || this.currentEngine;
+
+ // Save the current value in the form history
+ if (
+ aData &&
+ !PrivateBrowsingUtils.isWindowPrivate(window) &&
+ lazy.FormHistory.enabled &&
+ aData.length <=
+ lazy.SearchSuggestionController.SEARCH_HISTORY_MAX_VALUE_LENGTH
+ ) {
+ lazy.FormHistory.update({
+ op: "bump",
+ fieldname: textBox.getAttribute("autocompletesearchparam"),
+ value: aData,
+ source: engine.name,
+ }).catch(error =>
+ console.error(
+ "Saving search to form history failed: " + error.message
+ )
+ );
+ }
+
+ let submission = engine.getSubmission(aData, null, "searchbar");
+
+ // If we hit here, we come either from a one-off, a plain search or a suggestion.
+ const details = {
+ isOneOff,
+ isSuggestion: !isOneOff && this.telemetrySelectedIndex != -1,
+ url: submission.uri,
+ };
+
+ this.telemetrySelectedIndex = -1;
+
+ BrowserSearchTelemetry.recordSearch(
+ gBrowser.selectedBrowser,
+ engine,
+ "searchbar",
+ details
+ );
+ // null parameter below specifies HTML response for search
+ let params = {
+ postData: submission.postData,
+ globalHistoryOptions: {
+ triggeringSearchEngine: engine.name,
+ },
+ };
+ if (aParams) {
+ for (let key in aParams) {
+ params[key] = aParams[key];
+ }
+ }
+ openTrustedLinkIn(submission.uri.spec, aWhere, params);
+ }
+
+ disconnectedCallback() {
+ this.destroy();
+ while (this.firstChild) {
+ this.firstChild.remove();
+ }
+ }
+
+ /**
+ * Determines if we should select all the text in the searchbar based on the
+ * searchbar state, and whether the selection is empty.
+ */
+ _maybeSelectAll() {
+ if (
+ !this._preventClickSelectsAll &&
+ document.activeElement == this._textbox &&
+ this._textbox.selectionStart == this._textbox.selectionEnd
+ ) {
+ this.select();
+ }
+ }
+
+ _setupEventListeners() {
+ this.addEventListener("click", event => {
+ this._maybeSelectAll();
+ });
+
+ this.addEventListener(
+ "DOMMouseScroll",
+ event => {
+ if (event.getModifierState("Accel")) {
+ this.selectEngine(event, event.detail > 0);
+ }
+ },
+ true
+ );
+
+ this.addEventListener("input", event => {
+ this.updateGoButtonVisibility();
+ });
+
+ this.addEventListener("drop", event => {
+ this.updateGoButtonVisibility();
+ });
+
+ this.addEventListener(
+ "blur",
+ event => {
+ // Reset the flag since we can't capture enter keyup event if the event happens
+ // after moving the focus.
+ this._needBrowserFocusAtEnterKeyUp = false;
+
+ // If the input field is still focused then a different window has
+ // received focus, ignore the next focus event.
+ this._ignoreFocus = document.activeElement == this._textbox;
+ },
+ true
+ );
+
+ this.addEventListener(
+ "focus",
+ event => {
+ // Speculatively connect to the current engine's search URI (and
+ // suggest URI, if different) to reduce request latency
+ this.currentEngine.speculativeConnect({
+ window,
+ originAttributes: gBrowser.contentPrincipal.originAttributes,
+ });
+
+ if (this._ignoreFocus) {
+ // This window has been re-focused, don't show the suggestions
+ this._ignoreFocus = false;
+ return;
+ }
+
+ // Don't open the suggestions if there is no text in the textbox.
+ if (!this._textbox.value) {
+ return;
+ }
+
+ // Don't open the suggestions if the mouse was used to focus the
+ // textbox, that will be taken care of in the click handler.
+ if (
+ Services.focus.getLastFocusMethod(window) &
+ Services.focus.FLAG_BYMOUSE
+ ) {
+ return;
+ }
+
+ this.openSuggestionsPanel();
+ },
+ true
+ );
+
+ this.addEventListener("mousedown", event => {
+ this._preventClickSelectsAll = this._textbox.focused;
+ // Ignore right clicks
+ if (event.button != 0) {
+ return;
+ }
+
+ // Ignore clicks on the search go button.
+ if (event.originalTarget.classList.contains("search-go-button")) {
+ return;
+ }
+
+ // Ignore clicks on menu items in the input's context menu.
+ if (event.originalTarget.localName == "menuitem") {
+ return;
+ }
+
+ let isIconClick = event.originalTarget.classList.contains(
+ "searchbar-search-button"
+ );
+
+ // Hide popup when icon is clicked while popup is open
+ if (isIconClick && this.textbox.popup.popupOpen) {
+ this.textbox.popup.closePopup();
+ } else if (isIconClick || this._textbox.value) {
+ // Open the suggestions whenever clicking on the search icon or if there
+ // is text in the textbox.
+ this.openSuggestionsPanel(true);
+ }
+ });
+ }
+
+ _setupTextboxEventListeners() {
+ this.textbox.addEventListener("input", event => {
+ this.textbox.popup.removeAttribute("showonlysettings");
+ });
+
+ this.textbox.addEventListener("dragover", event => {
+ let types = event.dataTransfer.types;
+ if (
+ types.includes("text/plain") ||
+ types.includes("text/x-moz-text-internal")
+ ) {
+ event.preventDefault();
+ }
+ });
+
+ this.textbox.addEventListener("drop", event => {
+ let dataTransfer = event.dataTransfer;
+ let data = dataTransfer.getData("text/plain");
+ if (!data) {
+ data = dataTransfer.getData("text/x-moz-text-internal");
+ }
+ if (data) {
+ event.preventDefault();
+ this.textbox.value = data;
+ this.openSuggestionsPanel();
+ }
+ });
+
+ this.textbox.addEventListener("contextmenu", event => {
+ if (!this._menupopup) {
+ this._buildContextMenu();
+ }
+
+ this._textbox.closePopup();
+
+ // Make sure the context menu isn't opened via keyboard shortcut. Check for text selection
+ // before updating the state of any menu items.
+ if (event.button) {
+ this._maybeSelectAll();
+ }
+
+ // Update disabled state of menu items
+ for (let item of this._menupopup.querySelectorAll("menuitem[cmd]")) {
+ let command = item.getAttribute("cmd");
+ let controller =
+ document.commandDispatcher.getControllerForCommand(command);
+ item.disabled = !controller.isCommandEnabled(command);
+ }
+
+ let pasteEnabled = document.commandDispatcher
+ .getControllerForCommand("cmd_paste")
+ .isCommandEnabled("cmd_paste");
+ this._pasteAndSearchMenuItem.disabled = !pasteEnabled;
+
+ this._menupopup.openPopupAtScreen(event.screenX, event.screenY, true);
+
+ event.preventDefault();
+ });
+ }
+
+ _initTextbox() {
+ if (this.parentNode.parentNode.localName == "toolbarpaletteitem") {
+ return;
+ }
+
+ this.setAttribute("role", "combobox");
+ this.setAttribute("aria-owns", this.textbox.popup.id);
+
+ // 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.
+ Object.defineProperty(this.textbox, "searchParam", {
+ get() {
+ return (
+ this.getAttribute("autocompletesearchparam") +
+ (PrivateBrowsingUtils.isWindowPrivate(window) ? "|private" : "")
+ );
+ },
+ set(val) {
+ this.setAttribute("autocompletesearchparam", val);
+ },
+ });
+
+ Object.defineProperty(this.textbox, "selectedButton", {
+ get() {
+ return this.popup.oneOffButtons.selectedButton;
+ },
+ set(val) {
+ this.popup.oneOffButtons.selectedButton = val;
+ },
+ });
+
+ // This is implemented so that when textbox.value is set directly (e.g.,
+ // by tests), the one-off query is updated.
+ this.textbox.onBeforeValueSet = aValue => {
+ if (this.textbox.popup._oneOffButtons) {
+ this.textbox.popup.oneOffButtons.query = aValue;
+ }
+ return aValue;
+ };
+
+ // Returns true if the event is handled by us, false otherwise.
+ this.textbox.onBeforeHandleKeyDown = aEvent => {
+ if (aEvent.getModifierState("Accel")) {
+ if (
+ aEvent.keyCode == KeyEvent.DOM_VK_DOWN ||
+ aEvent.keyCode == KeyEvent.DOM_VK_UP
+ ) {
+ this.selectEngine(aEvent, aEvent.keyCode == KeyEvent.DOM_VK_DOWN);
+ return true;
+ }
+ return false;
+ }
+
+ if (
+ (AppConstants.platform == "macosx" &&
+ aEvent.keyCode == KeyEvent.DOM_VK_F4) ||
+ (aEvent.getModifierState("Alt") &&
+ (aEvent.keyCode == KeyEvent.DOM_VK_DOWN ||
+ aEvent.keyCode == KeyEvent.DOM_VK_UP))
+ ) {
+ if (!this.textbox.openSearch()) {
+ aEvent.preventDefault();
+ aEvent.stopPropagation();
+ return true;
+ }
+ }
+
+ let popup = this.textbox.popup;
+ if (popup.popupOpen) {
+ let suggestionsHidden =
+ popup.richlistbox.getAttribute("collapsed") == "true";
+ let numItems = suggestionsHidden ? 0 : popup.matchCount;
+ return popup.oneOffButtons.handleKeyDown(aEvent, numItems, true);
+ } else if (aEvent.keyCode == KeyEvent.DOM_VK_ESCAPE) {
+ if (this.textbox.editor.canUndo) {
+ this.textbox.editor.undoAll();
+ } else {
+ this.textbox.select();
+ }
+ return true;
+ }
+ return false;
+ };
+
+ // 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.
+ this.textbox.openPopup = () => {
+ // 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;
+ }
+
+ let popup = this.textbox.popup;
+ if (!popup.mPopupOpen) {
+ // Initially the panel used for the searchbar (PopupSearchAutoComplete
+ // in browser.xhtml) 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;
+
+ // 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.textbox;
+ // clear any previous selection, see bugs 400671 and 488357
+ popup.selectedIndex = -1;
+
+ // Ensure the panel has a meaningful initial size and doesn't grow
+ // unconditionally.
+ let { width } = window.windowUtils.getBoundsWithoutFlushing(this);
+ if (popup.oneOffButtons) {
+ // We have a min-width rule on search-panel-one-offs to show at
+ // least 4 buttons, so take that into account here.
+ width = Math.max(width, popup.oneOffButtons.buttonWidth * 4);
+ }
+
+ popup.style.setProperty("--panel-width", width + "px");
+ popup._invalidate();
+ popup.openPopup(this, "after_start");
+ }
+ };
+
+ this.textbox.openSearch = () => {
+ if (!this.textbox.popupOpen) {
+ this.openSuggestionsPanel();
+ return false;
+ }
+ return true;
+ };
+
+ this.textbox.handleEnter = event => {
+ // 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.textbox.selectedButton &&
+ this.textbox.selectedButton.getAttribute("anonid") ==
+ "addengine-menu-button"
+ ) {
+ this.textbox.selectedButton.open = !this.textbox.selectedButton.open;
+ return true;
+ }
+ // Otherwise, "call super": do what the autocomplete binding's
+ // handleEnter implementation does.
+ return this.textbox.mController.handleEnter(false, event || null);
+ };
+
+ // override |onTextEntered| in autocomplete.xml
+ this.textbox.onTextEntered = event => {
+ this.textbox.editor.clearUndoRedo();
+
+ let engine;
+ let oneOff = this.textbox.selectedButton;
+ if (oneOff) {
+ if (!oneOff.engine) {
+ oneOff.doCommand();
+ return;
+ }
+ engine = oneOff.engine;
+ }
+ if (this.textbox.popupSelectedIndex != -1) {
+ this.telemetrySelectedIndex = this.textbox.popupSelectedIndex;
+ this.textbox.popupSelectedIndex = -1;
+ }
+ this.handleSearchCommand(event, engine);
+ };
+
+ this.textbox.onbeforeinput = event => {
+ if (event.data && this._needBrowserFocusAtEnterKeyUp) {
+ // Ignore char key input while processing enter key.
+ event.preventDefault();
+ }
+ };
+
+ this.textbox.onkeyup = event => {
+ // Pressing Enter key while pressing Meta key, and next, even when
+ // releasing Enter key before releasing Meta key, the keyup event is not
+ // fired. Therefore, if Enter keydown is detecting, continue the post
+ // processing for Enter key when any keyup event is detected.
+ if (this._needBrowserFocusAtEnterKeyUp) {
+ this._needBrowserFocusAtEnterKeyUp = false;
+ gBrowser.selectedBrowser.focus();
+ }
+ };
+ }
+
+ _buildContextMenu() {
+ const raw = `
+ <menuitem data-l10n-id="text-action-undo" cmd="cmd_undo"/>
+ <menuitem data-l10n-id="text-action-redo" cmd="cmd_redo"/>
+ <menuseparator/>
+ <menuitem data-l10n-id="text-action-cut" cmd="cmd_cut"/>
+ <menuitem data-l10n-id="text-action-copy" cmd="cmd_copy"/>
+ <menuitem data-l10n-id="text-action-paste" cmd="cmd_paste"/>
+ <menuitem class="searchbar-paste-and-search"/>
+ <menuitem data-l10n-id="text-action-delete" cmd="cmd_delete"/>
+ <menuitem data-l10n-id="text-action-select-all" cmd="cmd_selectAll"/>
+ <menuseparator/>
+ <menuitem class="searchbar-clear-history"/>
+ `;
+
+ this._menupopup = this.querySelector(".textbox-contextmenu");
+
+ let frag = MozXULElement.parseXULToFragment(raw);
+
+ // Insert attributes that come from localized properties
+ this._pasteAndSearchMenuItem = frag.querySelector(
+ ".searchbar-paste-and-search"
+ );
+ this._pasteAndSearchMenuItem.setAttribute(
+ "label",
+ this._stringBundle.getString("cmd_pasteAndSearch")
+ );
+
+ let clearHistoryItem = frag.querySelector(".searchbar-clear-history");
+ clearHistoryItem.setAttribute(
+ "label",
+ this._stringBundle.getString("cmd_clearHistory")
+ );
+ clearHistoryItem.setAttribute(
+ "accesskey",
+ this._stringBundle.getString("cmd_clearHistory_accesskey")
+ );
+
+ this._menupopup.appendChild(frag);
+
+ this._menupopup.addEventListener("command", event => {
+ switch (event.originalTarget) {
+ case this._pasteAndSearchMenuItem:
+ this.select();
+ goDoCommand("cmd_paste");
+ this.handleSearchCommand(event);
+ break;
+ case clearHistoryItem:
+ let param = this.textbox.getAttribute("autocompletesearchparam");
+ lazy.FormHistory.update({ op: "remove", fieldname: param });
+ this.textbox.value = "";
+ break;
+ default:
+ let cmd = event.originalTarget.getAttribute("cmd");
+ if (cmd) {
+ let controller =
+ document.commandDispatcher.getControllerForCommand(cmd);
+ controller.doCommand(cmd);
+ }
+ break;
+ }
+ });
+ }
+ }
+
+ customElements.define("searchbar", MozSearchbar);
+}