diff options
Diffstat (limited to 'browser/components/search/content')
-rw-r--r-- | browser/components/search/content/autocomplete-popup.js | 289 | ||||
-rw-r--r-- | browser/components/search/content/contentSearchHandoffUI.js | 152 | ||||
-rw-r--r-- | browser/components/search/content/contentSearchUI.css | 160 | ||||
-rw-r--r-- | browser/components/search/content/contentSearchUI.js | 1021 | ||||
-rw-r--r-- | browser/components/search/content/searchbar.js | 907 |
5 files changed, 2529 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..2c84bf8cd7 --- /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.getIconURL(); + if (uri) { + this.setAttribute("src", uri); + } 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..986a1b4d82 --- /dev/null +++ b/browser/components/search/content/searchbar.js @@ -0,0 +1,907 @@ +/* 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" role="button" keyNav="false" aria-expanded="false" aria-controls="PopupSearchAutoComplete" aria-haspopup="true"> + <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" role="button" keyNav="false" 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") { + // 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"); + + 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; + } + + // Ensure the popup header is updated if the user has somehow + // managed to open the popup before the search service has finished + // initializing. + this._textbox.popup.updateHeader(); + // 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" + ); + } + + // 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(); + let searchIcon = document.querySelector(".searchbar-search-button"); + searchIcon.setAttribute("aria-expanded", "true"); + + 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) + ); + } + + 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, + }; + + this.telemetrySelectedIndex = -1; + + BrowserSearchTelemetry.recordSearch( + gBrowser.selectedBrowser, + engine, + "searchbar", + details + ); + + // Record when the user uses the search bar + Services.prefs.setStringPref( + "browser.search.widget.lastUsed", + new Date().toISOString() + ); + + // 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(); + let searchIcon = document.querySelector(".searchbar-search-button"); + searchIcon.setAttribute("aria-expanded", "false"); + } 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; + let searchIcon = document.querySelector(".searchbar-search-button"); + searchIcon.setAttribute("aria-expanded", popup.popupOpen); + 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; + let searchIcon = document.querySelector(".searchbar-search-button"); + 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"); + searchIcon.setAttribute("aria-expanded", "true"); + } + }; + + 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); +} |