diff options
Diffstat (limited to 'browser/components/search/content/contentSearchUI.js')
-rw-r--r-- | browser/components/search/content/contentSearchUI.js | 1021 |
1 files changed, 1021 insertions, 0 deletions
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; +})(); |