Bug 1752251 - Implement best match rows in the urlbar view. r=dao

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
<span class="urlbarView-row">
  <span class="urlbarView-row-inner">
    <img class="urlbarView-favicon">
    <span class="urlbarView-row-body">
      <div class="urlbarView-row-body-top">
        <div class="urlbarView-row-body-top-no-wrap">
          <span class="urlbarView-title"></span>
          <span class="urlbarView-title-separator"></span>
        </div>
        <span class="urlbarView-url"></span>
      </div>
      <div class="urlbarView-row-body-bottom">Sponsored</div>
    </span>
  </span>
</span>
```

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
This commit is contained in:
Drew Willcoxon 2022-02-01 22:18:50 +00:00
Родитель f8aa71dcae
Коммит 038a2d68a6
5 изменённых файлов: 382 добавлений и 37 удалений

Просмотреть файл

@ -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")) {

Просмотреть файл

@ -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 }

Просмотреть файл

@ -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

Просмотреть файл

@ -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 }
);
}

Просмотреть файл

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