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]) {