From 038a2d68a65ba9e056c5894391d528af72e24cd0 Mon Sep 17 00:00:00 2001 From: Drew Willcoxon Date: Tue, 1 Feb 2022 22:18:50 +0000 Subject: [PATCH] Bug 1752251 - Implement best match rows in the urlbar view. r=dao MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This creates a new type of `bestmatch` row in the view. The UX spec is here: https://www.figma.com/file/seJ2ZA4v3FgoV7jCxUR74B/Firefox-Suggest?node-id=5235%3A1284 (See: “Best match” proposal for Firefox 99) Best match rows look similar to standard rows except they have a large 52x52 icon with the title and URL vertically centered next to it. Best match rows that are sponsored also have the usual "Sponsored" label. We're targeting 99 for the initial MVP version of this feature. For the MVP, best matches will always be quick suggest results. Long term, there's been discussion about incorporating history and bookmarks too. Since 99 is coming up soon and we don't have much time, I did what I think is the most straightforward thing and added another new row type, `bestmatch`. I considered using the usual row DOM, but it's tricky because for best match we need to show both the URL and sponsored label, and the sponsored label needs to be shown below the title. The way we show the sponsored label for typical quick suggest rows is by putting it in the action text and wrapping it below the title, but that doesn't work for best match since it must show the URL. However, best match rows do look similar enough to the usual rows that I think it would be worth modifying the usual row DOM so that it uses this new best match DOM. That would simplify the JS and CSS. It's also a much larger, riskier change and there might be disagreement about it, and I don't want to block this feature on that, so I'd like to come back to it. I have a WIP in D137095 that also includes some general refactoring and simplification. I could have implemented this as a dynamic result type like the onboarding tab-to-search, but that would couple best match to a particular provider -- quick suggest -- and as I mentioned we may end up expanding best match to all types of results. I don't want to add a new type and all of that code if we know we may remove it later. This revision relies on a new `result.isBestMatch` property that will be set for quick suggest best matches in D137250. The best match DOM looks like this: ```lang=html
Sponsored
``` Finally, this also adds a "Best Match" group label in the view. A few notes on this: * The string isn't finalized yet but we can easily update it once it is. * Since right now best match will be en-US only and is related to Firefox Suggest, I added the string to firefoxSuggest.ftl, which is not localized. * In D137250 I'm adding a `browser.urlbar.bestMatch.enabled` pref and moving the caching of this string behind that pref. Differential Revision: https://phabricator.services.mozilla.com/D137097 --- browser/components/urlbar/UrlbarView.jsm | 184 ++++++++++++++---- .../urlbar/content/firefoxSuggest.ftl | 4 + .../urlbar/tests/browser/browser.ini | 1 + .../urlbar/tests/browser/browser_bestMatch.js | 156 +++++++++++++++ browser/themes/shared/urlbarView.inc.css | 74 ++++++- 5 files changed, 382 insertions(+), 37 deletions(-) create mode 100644 browser/components/urlbar/tests/browser/browser_bestMatch.js diff --git a/browser/components/urlbar/UrlbarView.jsm b/browser/components/urlbar/UrlbarView.jsm index 054ca74957eb..648058d56286 100644 --- a/browser/components/urlbar/UrlbarView.jsm +++ b/browser/components/urlbar/UrlbarView.jsm @@ -1148,40 +1148,7 @@ class UrlbarView { item._content.appendChild(url); item._elements.set("url", url); - // Usually we create all child elements for the row regardless of whether - // the specific result will use them, but we don't expect the vast majority - // of results to have help URLs, so as an optimization, only create the help - // button if the result will use it. - if (result.payload.helpUrl) { - let helpButton = this._createElement("span"); - helpButton.className = "urlbarView-help"; - helpButton.setAttribute("role", "button"); - if (result.payload.helpL10nId) { - helpButton.setAttribute("data-l10n-id", result.payload.helpL10nId); - } - item.appendChild(helpButton); - item._elements.set("helpButton", helpButton); - item._content.setAttribute("selectable", "true"); - - // If the content is marked as selectable, the screen reader will not be - // able to read the text directly child of the "urlbarView-row". As the - // group label is shown as pseudo element of urlbarView-row now, it isn't - // readable. To avoid it, we add an element for aria-label explictly, - // and set group label that should be read into it in _updateIndices(). - const groupAriaLabel = this._createElement("span"); - groupAriaLabel.className = "urlbarView-group-aria-label"; - item._content.insertBefore(groupAriaLabel, item._content.firstChild); - item._elements.set("groupAriaLabel", groupAriaLabel); - - // Remove role=option on the row and set it on row-inner since the latter - // is the selectable logical row element when the help button is present. - // Since row-inner is not a child of the role=listbox element (the row - // container, this._rows), screen readers will not automatically recognize - // it as a listbox option. To compensate, set role=presentation on the - // row so that screen readers ignore it. - item.setAttribute("role", "presentation"); - item._content.setAttribute("role", "option"); - } + this._maybeCreateRowHelpButton(item, result); } _createRowContentForTip(item) { @@ -1268,6 +1235,91 @@ class UrlbarView { } } + _createRowContentForBestMatch(item, result) { + let favicon = this._createElement("img"); + favicon.className = "urlbarView-favicon"; + item._content.appendChild(favicon); + item._elements.set("favicon", favicon); + + let typeIcon = this._createElement("span"); + typeIcon.className = "urlbarView-type-icon"; + item._content.appendChild(typeIcon); + + let body = this._createElement("span"); + body.className = "urlbarView-row-body"; + item._content.appendChild(body); + + let top = this._createElement("div"); + top.className = "urlbarView-row-body-top"; + body.appendChild(top); + + let noWrap = this._createElement("div"); + noWrap.className = "urlbarView-row-body-top-no-wrap"; + top.appendChild(noWrap); + item._elements.set("noWrap", noWrap); + + let title = this._createElement("span"); + title.className = "urlbarView-title"; + noWrap.appendChild(title); + item._elements.set("title", title); + + let titleSeparator = this._createElement("span"); + titleSeparator.className = "urlbarView-title-separator"; + noWrap.appendChild(titleSeparator); + item._elements.set("titleSeparator", titleSeparator); + + let url = this._createElement("span"); + url.className = "urlbarView-url"; + top.appendChild(url); + item._elements.set("url", url); + + let bottom = this._createElement("div"); + bottom.className = "urlbarView-row-body-bottom"; + body.appendChild(bottom); + item._elements.set("bottom", bottom); + + this._maybeCreateRowHelpButton(item, result); + } + + _maybeCreateRowHelpButton(item, result) { + // Usually we create all child elements for the row regardless of whether + // the specific result will use them, but we don't expect the vast majority + // of results to have help URLs, so as an optimization, only create the help + // button if the result will use it. + if (!result.payload.helpUrl) { + return; + } + + let helpButton = this._createElement("span"); + helpButton.className = "urlbarView-help"; + helpButton.setAttribute("role", "button"); + if (result.payload.helpL10nId) { + helpButton.setAttribute("data-l10n-id", result.payload.helpL10nId); + } + item.appendChild(helpButton); + item._elements.set("helpButton", helpButton); + item._content.setAttribute("selectable", "true"); + + // If the content is marked as selectable, the screen reader will not be + // able to read the text directly child of the "urlbarView-row". As the + // group label is shown as pseudo element of urlbarView-row now, it isn't + // readable. To avoid it, we add an element for aria-label explictly, + // and set group label that should be read into it in _updateIndices(). + const groupAriaLabel = this._createElement("span"); + groupAriaLabel.className = "urlbarView-group-aria-label"; + item._content.insertBefore(groupAriaLabel, item._content.firstChild); + item._elements.set("groupAriaLabel", groupAriaLabel); + + // Remove role=option on the row and set it on row-inner since the latter + // is the selectable logical row element when the help button is present. + // Since row-inner is not a child of the role=listbox element (the row + // container, this._rows), screen readers will not automatically recognize + // it as a listbox option. To compensate, set role=presentation on the + // row so that screen readers ignore it. + item.setAttribute("role", "presentation"); + item._content.setAttribute("role", "option"); + } + _updateRow(item, result) { let oldResult = item.result; let oldResultType = item.result && item.result.type; @@ -1284,6 +1336,7 @@ class UrlbarView { (oldResultType == UrlbarUtils.RESULT_TYPE.DYNAMIC && result.type == UrlbarUtils.RESULT_TYPE.DYNAMIC && oldResult.dynamicType != result.dynamicType) || + oldResult.isBestMatch != result.isBestMatch || !!result.payload.helpUrl != item._elements.has("helpButton"); if (needsNewContent) { @@ -1299,6 +1352,8 @@ class UrlbarView { this._createRowContentForTip(item); } else if (item.result.type == UrlbarUtils.RESULT_TYPE.DYNAMIC) { this._createRowContentForDynamicType(item, result); + } else if (item.result.isBestMatch) { + this._createRowContentForBestMatch(item, result); } else { this._createRowContent(item, result); } @@ -1327,6 +1382,10 @@ class UrlbarView { return; } else if (result.providerName == "TabToSearch") { item.setAttribute("type", "tabtosearch"); + } else if (result.isBestMatch) { + item.setAttribute("type", "bestmatch"); + this._updateRowForBestMatch(item, result); + return; } else { item.removeAttribute("type"); } @@ -1659,6 +1718,48 @@ class UrlbarView { } } + _updateRowForBestMatch(item, result) { + let favicon = item._elements.get("favicon"); + favicon.src = this._iconForResult(result); + + let title = item._elements.get("title"); + this._setResultTitle(result, title); + title._tooltip = result.title; + if (title.hasAttribute("overflow")) { + title.setAttribute("title", title._tooltip); + } else { + title.removeAttribute("title"); + } + + let url = item._elements.get("url"); + this._addTextContentWithHighlights( + url, + result.payload.displayUrl, + result.payloadHighlights.displayUrl || [] + ); + url._tooltip = result.payload.displayUrl; + if (url.hasAttribute("overflow")) { + url.setAttribute("title", url._tooltip); + } else { + url.removeAttribute("title"); + } + + let bottom = item._elements.get("bottom"); + if (result.payload.isSponsored) { + this._setElementL10n(bottom, { id: "urlbar-result-action-sponsored" }); + } else { + this._removeElementL10n(bottom); + } + + let helpButton = item._elements.get("helpButton"); + if (helpButton) { + helpButton.id = item.id + "-help"; + item.toggleAttribute("has-help", true); + } else { + item.removeAttribute("has-help"); + } + } + /** * Performs a final pass over all rows in the view after a view update, stale * rows are removed, and other changes to the number of rows. Sets `rowIndex` @@ -1742,6 +1843,9 @@ class UrlbarView { this._queryContext?.searchString && !row.result.heuristic ) { + if (row.result.isBestMatch) { + return { id: "urlbar-group-best-match" }; + } switch (row.result.type) { case UrlbarUtils.RESULT_TYPE.KEYWORD: case UrlbarUtils.RESULT_TYPE.REMOTE_TAB: @@ -1776,7 +1880,10 @@ class UrlbarView { // again, we'll get new overflow events if needed. this._setElementOverflowing(row._elements.get("title"), false); this._setElementOverflowing(row._elements.get("url"), false); - this._setElementOverflowing(row._elements.get("tagsContainer"), false); + let tagsContainer = row._elements.get("tagsContainer"); + if (tagsContainer) { + this._setElementOverflowing(tagsContainer, false); + } } } @@ -2176,7 +2283,12 @@ class UrlbarView { ]; if (UrlbarPrefs.get("groupLabels.enabled")) { - idArgs.push({ id: "urlbar-group-firefox-suggest" }); + idArgs.push( + ...[ + { id: "urlbar-group-best-match" }, + { id: "urlbar-group-firefox-suggest" }, + ] + ); } if (UrlbarPrefs.get("quickSuggestEnabled")) { diff --git a/browser/components/urlbar/content/firefoxSuggest.ftl b/browser/components/urlbar/content/firefoxSuggest.ftl index 6d493da40fb1..e481522dc5c6 100644 --- a/browser/components/urlbar/content/firefoxSuggest.ftl +++ b/browser/components/urlbar/content/firefoxSuggest.ftl @@ -11,6 +11,10 @@ ## These strings are used in the urlbar panel. +# A label shown above the best match group in the urlbar results. +urlbar-group-best-match = + .label = { -firefox-suggest-brand-name } · Best Match + # Tooltip text for the help button shown in Firefox Suggest urlbar results. firefox-suggest-urlbar-learn-more = .title = Learn more about { -firefox-suggest-brand-name } diff --git a/browser/components/urlbar/tests/browser/browser.ini b/browser/components/urlbar/tests/browser/browser.ini index d39004702fad..c9084c1c184c 100644 --- a/browser/components/urlbar/tests/browser/browser.ini +++ b/browser/components/urlbar/tests/browser/browser.ini @@ -56,6 +56,7 @@ https_first_disabled = true [browser_autoFill_typed.js] [browser_autoFill_undo.js] [browser_autoOpen.js] +[browser_bestMatch.js] [browser_blanking.js] support-files = file_blank_but_not_blank.html diff --git a/browser/components/urlbar/tests/browser/browser_bestMatch.js b/browser/components/urlbar/tests/browser/browser_bestMatch.js new file mode 100644 index 000000000000..79f6f4584be9 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_bestMatch.js @@ -0,0 +1,156 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests best match rows in the view. + +"use strict"; + +// Tests a non-sponsored best match row. +add_task(async function nonsponsored() { + let result = makeBestMatchResult(); + await withProvider(result, async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await checkBestMatchRow({ result }); + await UrlbarTestUtils.promisePopupClose(window); + }); +}); + +// Tests a non-sponsored best match row with a help button. +add_task(async function nonsponsoredHelpButton() { + let result = makeBestMatchResult({ helpUrl: "https://example.com/help" }); + await withProvider(result, async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await checkBestMatchRow({ result, hasHelpButton: true }); + await UrlbarTestUtils.promisePopupClose(window); + }); +}); + +// Tests a sponsored best match row. +add_task(async function sponsored() { + let result = makeBestMatchResult({ isSponsored: true }); + await withProvider(result, async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await checkBestMatchRow({ result, isSponsored: true }); + await UrlbarTestUtils.promisePopupClose(window); + }); +}); + +// Tests a sponsored best match row with a help button. +add_task(async function sponsoredHelpButton() { + let result = makeBestMatchResult({ + isSponsored: true, + helpUrl: "https://example.com/help", + }); + await withProvider(result, async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await checkBestMatchRow({ result, isSponsored: true, hasHelpButton: true }); + await UrlbarTestUtils.promisePopupClose(window); + }); +}); + +async function checkBestMatchRow({ + result, + isSponsored = false, + hasHelpButton = false, +}) { + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 1, + "One result is present" + ); + + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + let { row } = details.element; + + Assert.equal(row.getAttribute("type"), "bestmatch", "row[type] is bestmatch"); + + let favicon = row._elements.get("favicon"); + Assert.ok(favicon, "Row has a favicon"); + + let title = row._elements.get("title"); + Assert.ok(title, "Row has a title"); + Assert.ok(title.textContent, "Row title has non-empty textContext"); + Assert.equal(title.textContent, result.payload.title, "Row title is correct"); + + let url = row._elements.get("url"); + Assert.ok(url, "Row has a URL"); + Assert.ok(url.textContent, "Row URL has non-empty textContext"); + Assert.equal( + url.textContent, + result.payload.displayUrl, + "Row URL is correct" + ); + + let bottom = row._elements.get("bottom"); + Assert.ok(bottom, "Row has a bottom"); + Assert.equal( + !!result.payload.isSponsored, + isSponsored, + "Sanity check: Row's expected isSponsored matches result's" + ); + if (isSponsored) { + Assert.equal( + bottom.textContent, + "Sponsored", + "Sponsored row bottom has Sponsored textContext" + ); + } else { + Assert.equal( + bottom.textContent, + "", + "Non-sponsored row bottom has empty textContext" + ); + } + + let helpButton = row._elements.get("helpButton"); + Assert.equal( + !!result.payload.helpUrl, + hasHelpButton, + "Sanity check: Row's expected hasHelpButton matches result" + ); + if (hasHelpButton) { + Assert.ok(helpButton, "Row with helpUrl has a helpButton"); + } else { + Assert.ok(!helpButton, "Row without helpUrl does not have a helpButton"); + } +} + +async function withProvider(result, callback) { + let provider = new UrlbarTestUtils.TestProvider({ + results: [result], + priority: Infinity, + }); + UrlbarProvidersManager.registerProvider(provider); + try { + await callback(); + } finally { + UrlbarProvidersManager.unregisterProvider(provider); + } +} + +function makeBestMatchResult(payloadExtra = {}) { + return Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.SEARCH, + ...UrlbarResult.payloadAndSimpleHighlights([], { + title: "Test best match", + url: "https://example.com/best-match", + ...payloadExtra, + }) + ), + { isBestMatch: true } + ); +} diff --git a/browser/themes/shared/urlbarView.inc.css b/browser/themes/shared/urlbarView.inc.css index fa93d6f3f3dd..61e26d1ba9a2 100644 --- a/browser/themes/shared/urlbarView.inc.css +++ b/browser/themes/shared/urlbarView.inc.css @@ -155,7 +155,8 @@ max-width: 100% !important; flex-basis: 100%; } - .urlbarView-results[wrap] > .urlbarView-row[has-url] > .urlbarView-row-inner > .urlbarView-url { + .urlbarView-results[wrap] > .urlbarView-row[has-url] > .urlbarView-row-inner > .urlbarView-url, + .urlbarView-results[wrap] > .urlbarView-row[type=bestmatch] > .urlbarView-row-inner > .urlbarView-row-body > .urlbarView-row-body-top > .urlbarView-url { margin-top: 2px; } /* urlbarView-url is forced to be LTR for RTL locales, so set the padding based on the browser's directionality. */ @@ -170,6 +171,7 @@ .urlbarView[actionoverride] .urlbarView-results[wrap] > .urlbarView-row[has-url] > .urlbarView-row-inner > .urlbarView-no-wrap > .urlbarView-title-separator, .urlbarView-results[wrap] > .urlbarView-row[has-url]:not([type$=tab], [sponsored]) > .urlbarView-row-inner > .urlbarView-no-wrap > .urlbarView-title-separator, .urlbarView-results[wrap] > .urlbarView-row[has-url]:is([type=remotetab], [sponsored]):is(:hover, [selected]) > .urlbarView-row-inner > .urlbarView-no-wrap > .urlbarView-title-separator, + .urlbarView-results[wrap] > .urlbarView-row[type=bestmatch] > .urlbarView-row-inner > .urlbarView-row-body > .urlbarView-row-body-top > .urlbarView-row-body-top-no-wrap > .urlbarView-title-separator, .urlbarView-results[wrap] > .urlbarView-row[type=tabtosearch] > .urlbarView-row-inner > .urlbarView-no-wrap > .urlbarView-title-separator { display: none; } @@ -180,6 +182,13 @@ flex-basis: 100%; margin-inline-start: calc(var(--urlbarView-item-inline-padding) + var(--identity-box-margin-inline) + var(--urlbarView-favicon-width)); } + + .urlbarView-results[wrap] > .urlbarView-row[type=bestmatch] > .urlbarView-row-inner > .urlbarView-row-body > .urlbarView-row-body-top { + flex-wrap: wrap; + } + .urlbarView-results[wrap] > .urlbarView-row[type=bestmatch] > .urlbarView-row-inner > .urlbarView-row-body > .urlbarView-row-body-top > .urlbarView-row-body-top-no-wrap { + flex-basis: 100%; + } } /* We should always wrap tip results at narrow widths regardless of screen @@ -190,12 +199,14 @@ } .urlbarView-row:not([type=tip], [type=dynamic]) > .urlbarView-row-inner > .urlbarView-no-wrap > .urlbarView-title[overflow], +.urlbarView-row[type=bestmatch] > .urlbarView-row-inner > .urlbarView-row-body > .urlbarView-row-body-top > .urlbarView-row-body-top-no-wrap > .urlbarView-title[overflow], .urlbarView-tags[overflow], .urlbarView-url[overflow] { mask-image: linear-gradient(to left, transparent, black 2em); } .urlbarView-row:not([type=tip], [type=dynamic]) > .urlbarView-row-inner > .urlbarView-no-wrap > .urlbarView-title[overflow]:not([isurl]):-moz-locale-dir(rtl), +.urlbarView-row[type=bestmatch] > .urlbarView-row-inner > .urlbarView-row-body > .urlbarView-row-body-top > .urlbarView-row-body-top-no-wrap > .urlbarView-title[overflow]:-moz-locale-dir(rtl), .urlbarView-tags[overflow]:-moz-locale-dir(rtl) { mask-image: linear-gradient(to right, transparent, black 2em); } @@ -662,6 +673,67 @@ border-radius: 4px; } +/* Best match */ + +.urlbarView-row[type=bestmatch] > .urlbarView-row-inner { + align-items: center; + justify-content: start; +} + +.urlbarView-row[type=bestmatch] > .urlbarView-row-inner, +.urlbarView-results[wrap] > .urlbarView-row[type=bestmatch] > .urlbarView-row-inner { + flex-wrap: nowrap; +} + +.urlbarView-row[type=bestmatch] > .urlbarView-row-inner > .urlbarView-favicon { + width: 52px; + height: 52px; + flex-basis: 52px; + flex-shrink: 0; + flex-grow: 0; + border-radius: 2px; +} + +.urlbarView-row[type=bestmatch] > .urlbarView-row-inner > .urlbarView-row-body { + flex-grow: 1; + flex-shrink: 1; + min-width: 0; +} + +.urlbarView-row[type=bestmatch] > .urlbarView-row-inner > .urlbarView-row-body > .urlbarView-row-body-top { + display: flex; + flex-wrap: nowrap; + flex-direction: row; + align-items: center; + justify-content: start; +} + +.urlbarView-row[type=bestmatch] > .urlbarView-row-inner > .urlbarView-row-body > .urlbarView-row-body-top > .urlbarView-row-body-top-no-wrap { + display: flex; + flex-wrap: nowrap; + flex-direction: row; + align-items: center; + justify-content: start; + flex-shrink: 0; + min-width: 0; +} + +.urlbarView-results:not([wrap]) > .urlbarView-row[type=bestmatch] > .urlbarView-row-inner > .urlbarView-row-body > .urlbarView-row-body-top > .urlbarView-row-body-top-no-wrap { + /* Limit the title (which is inside .urlbarView-row-body-top-no-wrap) to 70% + of the width so the URL is never completely hidden. */ + max-width: 70%; +} + +.urlbarView-row[type=bestmatch] > .urlbarView-row-inner > .urlbarView-row-body > .urlbarView-row-body-bottom { + font-size: 0.73em; + opacity: 0.6; +} + +.urlbarView-row[type=bestmatch][selected] > .urlbarView-row-inner > .urlbarView-row-body > .urlbarView-row-body-bottom, +.urlbarView-row[type=bestmatch] > .urlbarView-row-inner[selected] > .urlbarView-row-body > .urlbarView-row-body-bottom { + opacity: 1; +} + /* Search one-offs */ #urlbar .search-one-offs:not([hidden]) {