Bug 1578435 - Update UrlbarView's selection model to support selection within "tip" results. r=adw
authorHarry Twyford <htwyford@mozilla.com>
Fri, 13 Sep 2019 22:47:41 +0000
changeset 493147 e4bef719042a585301fe1ffcd23a36928961d491
parent 493146 11196192f99609644e79a4dd20458c18f0f14429
child 493148 e756e70d1fd6d29d7aa7ef6da0a95c9bcbc9db52
push id95362
push userhtwyford@mozilla.com
push dateFri, 13 Sep 2019 22:52:49 +0000
treeherderautoland@e4bef719042a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersadw
bugs1578435
milestone71.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1578435 - Update UrlbarView's selection model to support selection within "tip" results. r=adw Differential Revision: https://phabricator.services.mozilla.com/D45455
browser/components/search/content/search-one-offs.js
browser/components/urlbar/UrlbarController.jsm
browser/components/urlbar/UrlbarInput.jsm
browser/components/urlbar/UrlbarView.jsm
browser/components/urlbar/tests/UrlbarTestUtils.jsm
browser/components/urlbar/tests/browser/browser.ini
browser/components/urlbar/tests/browser/browser_autocomplete_autoselect.js
browser/components/urlbar/tests/browser/browser_inputHistory.js
browser/components/urlbar/tests/browser/browser_keywordBookmarklets.js
browser/components/urlbar/tests/browser/browser_selectStaleResults.js
browser/components/urlbar/tests/browser/browser_tip_keyboard_selection.js
browser/components/urlbar/tests/browser/browser_urlbar_event_telemetry.js
browser/components/urlbar/tests/browser/browser_urlbar_speculative_connect.js
browser/components/urlbar/tests/browser/browser_urlbar_speculative_connect_not_with_client_cert.js
browser/themes/shared/urlbar-autocomplete.inc.css
--- a/browser/components/search/content/search-one-offs.js
+++ b/browser/components/search/content/search-one-offs.js
@@ -349,21 +349,27 @@ class SearchOneOffs {
       if (buttons[i] == this._selectedButton) {
         return i;
       }
     }
     return -1;
   }
 
   get selectedAutocompleteIndex() {
-    return (this._view || this.popup).selectedIndex;
+    if (!this.compact) {
+      return this.popup.selectedIndex;
+    }
+    return this._view.selectedRowIndex;
   }
 
   set selectedAutocompleteIndex(val) {
-    return ((this._view || this.popup).selectedIndex = val);
+    if (!this.compact) {
+      return (this.popup.selectedIndex = val);
+    }
+    return (this._view.selectedRowIndex = val);
   }
 
   get compact() {
     return this.getAttribute("compact") == "true";
   }
 
   get bundle() {
     if (!this._bundle) {
--- a/browser/components/urlbar/UrlbarController.jsm
+++ b/browser/components/urlbar/UrlbarController.jsm
@@ -281,17 +281,17 @@ class UrlbarController {
       event.preventDefault();
       return;
     }
 
     if (this.view.isOpen && executeAction && this._lastQueryContextWrapper) {
       let { queryContext } = this._lastQueryContextWrapper;
       let handled = this.view.oneOffSearchButtons.handleKeyPress(
         event,
-        this.view.visibleItemCount,
+        this.view.visibleRowCount,
         this.view.allowEmptySelection,
         queryContext.searchString
       );
       if (handled) {
         return;
       }
     }
 
--- a/browser/components/urlbar/UrlbarInput.jsm
+++ b/browser/components/urlbar/UrlbarInput.jsm
@@ -444,17 +444,17 @@ class UrlbarInput {
     );
 
     let where = openWhere || this._whereToOpen(event);
     openParams.allowInheritPrincipal = false;
     url = this._maybeCanonizeURL(event, url) || url.trim();
 
     this.controller.engagementEvent.record(event, {
       numChars,
-      selIndex: this.view.selectedIndex,
+      selIndex: this.view.selectedRowIndex,
       selType,
     });
 
     try {
       new URL(url);
     } catch (ex) {
       let browser = this.window.gBrowser.selectedBrowser;
       let lastLocationChange = browser.lastLocationChange;
@@ -491,17 +491,17 @@ class UrlbarInput {
    */
   pickResult(result, event) {
     let isCanonized = this.setValueFromResult(result, event);
     let where = this._whereToOpen(event);
     let openParams = {
       allowInheritPrincipal: false,
     };
 
-    let selIndex = this.view.selectedIndex;
+    let selIndex = this.view.selectedRowIndex;
     if (!result.payload.keywordOffer) {
       this.view.close();
     }
 
     this.controller.recordSelectedResult(event, result);
 
     if (isCanonized) {
       this.controller.engagementEvent.record(event, {
--- a/browser/components/urlbar/UrlbarView.jsm
+++ b/browser/components/urlbar/UrlbarView.jsm
@@ -79,34 +79,34 @@ class UrlbarView {
   setContextualTip(details) {
     if (!this.contextualTip) {
       this.contextualTip = new UrlbarContextualTip(this);
     }
     this.contextualTip.set(details);
 
     // Disable one off search buttons from appearing if
     // the contextual tip is the only item in the urlbar view.
-    if (this.visibleItemCount == 0) {
+    if (this.visibleRowCount == 0) {
       this._enableOrDisableOneOffSearches(false);
     }
 
     this._openPanel();
   }
 
   /**
    * Hides the contextual tip.
    */
   hideContextualTip() {
     if (this.contextualTip) {
       this.contextualTip.hide();
 
       // When the pending query has finished and there's 0 results then
       // close the urlbar view.
       this.input.lastQueryContextPromise.then(() => {
-        if (this.visibleItemCount == 0) {
+        if (this.visibleRowCount == 0) {
           this.close();
         }
       });
     }
   }
 
   /**
    * Removes the contextual tip from the DOM.
@@ -143,66 +143,80 @@ class UrlbarView {
   get allowEmptySelection() {
     return !(
       this._queryContext &&
       this._queryContext.results[0] &&
       this._queryContext.results[0].heuristic
     );
   }
 
-  get selectedIndex() {
-    if (!this.isOpen || !this._selected) {
+  get selectedRowIndex() {
+    if (!this.isOpen) {
       return -1;
     }
-    return this._selected.result.uiIndex;
+
+    let selectedRow = this._getSelectedRow();
+
+    if (!selectedRow) {
+      return -1;
+    }
+
+    return selectedRow.result.uiIndex;
   }
 
-  set selectedIndex(val) {
+  set selectedRowIndex(val) {
     if (!this.isOpen) {
       throw new Error(
         "UrlbarView: Cannot select an item if the view isn't open."
       );
     }
 
     if (val < 0) {
-      this._selectItem(null);
+      this._selectElement(null);
       return val;
     }
 
     let items = Array.from(this._rows.children).filter(r =>
-      this._isRowVisible(r)
+      this._isElementVisible(r)
     );
     if (val >= items.length) {
       throw new Error(`UrlbarView: Index ${val} is out of bounds.`);
     }
-    this._selectItem(items[val]);
+    this._selectElement(items[val]);
     return val;
   }
 
   /**
    * @returns {UrlbarResult}
    *   The currently selected result.
    */
   get selectedResult() {
-    if (!this.isOpen || !this._selected) {
+    if (!this.isOpen) {
       return null;
     }
-    return this._selected.result;
+
+    let selectedRow = this._getSelectedRow();
+
+    if (!selectedRow) {
+      return null;
+    }
+
+    return selectedRow.result;
   }
 
   /**
    * @returns {number}
    *   The number of visible results in the view.  Note that this may be larger
    *   than the number of results in the current query context since the view
    *   may be showing stale results.
    */
-  get visibleItemCount() {
+  get visibleRowCount() {
     let sum = 0;
     for (let row of this._rows.children) {
-      sum += Number(this._isRowVisible(row));
+      sum += Number(this._isElementVisible(row));
     }
     return sum;
   }
 
   /**
    * Moves the view selection forward or backward.
    *
    * @param {number} amount
@@ -216,55 +230,59 @@ class UrlbarView {
       throw new Error(
         "UrlbarView: Cannot select an item if the view isn't open."
       );
     }
 
     // Freeze results as the user is interacting with them.
     this.controller.cancelQuery();
 
-    let row = this._selected;
+    let selectedElement = this._selectedElement;
 
-    // Results over maxResults may be hidden and should not be selectable.
-    let lastElementChild = this._rows.lastElementChild;
-    while (lastElementChild && !this._isRowVisible(lastElementChild)) {
-      lastElementChild = lastElementChild.previousElementSibling;
-    }
+    // We cache the first and last rows since they will not change while
+    // selectBy is running.
+    let firstSelectableElement = this._getFirstSelectableElement();
+    // _getLastSelectableElement will not return an element that is over
+    // maxResults and thus may be hidden and not selectable.
+    let lastSelectableElement = this._getLastSelectableElement();
 
-    if (!row) {
-      this._selectItem(
-        reverse ? lastElementChild : this._rows.firstElementChild
+    if (!selectedElement) {
+      this._selectElement(
+        reverse ? lastSelectableElement : firstSelectableElement
       );
       return;
     }
-
     let endReached = reverse
-      ? row == this._rows.firstElementChild
-      : row == lastElementChild;
+      ? selectedElement == firstSelectableElement
+      : selectedElement == lastSelectableElement;
     if (endReached) {
       if (this.allowEmptySelection) {
-        row = null;
+        selectedElement = null;
       } else {
-        row = reverse ? lastElementChild : this._rows.firstElementChild;
+        selectedElement = reverse
+          ? lastSelectableElement
+          : firstSelectableElement;
       }
-      this._selectItem(row);
+      this._selectElement(selectedElement);
       return;
     }
 
     while (amount-- > 0) {
-      let next = reverse ? row.previousElementSibling : row.nextElementSibling;
+      let next = reverse
+        ? this._getPreviousSelectableElement(selectedElement)
+        : this._getNextSelectableElement(selectedElement);
       if (!next) {
         break;
       }
-      if (!this._isRowVisible(next)) {
+      if (!this._isElementVisible(next)) {
         continue;
       }
-      row = next;
+      selectedElement = next;
     }
-    this._selectItem(row);
+    this._selectElement(selectedElement);
   }
 
   removeAccessibleFocus() {
     this._setAccessibleFocus(null);
   }
 
   /**
    * Closes the view, cancelling the query if necessary.
@@ -321,23 +339,23 @@ class UrlbarView {
     this._queryContext = queryContext;
 
     this._updateResults(queryContext);
 
     let isFirstPreselectedResult = false;
     if (queryContext.lastResultCount == 0) {
       if (queryContext.preselected) {
         isFirstPreselectedResult = true;
-        this._selectItem(this._rows.firstElementChild, {
+        this._selectElement(this._getFirstSelectableElement(), {
           updateInput: false,
           setAccessibleFocus: this.controller._userSelectionBehavior == "arrow",
         });
       } else {
         // Clear the selection when we get a new set of results.
-        this._selectItem(null, {
+        this._selectElement(null, {
           updateInput: false,
         });
       }
       // Hide the one-off search buttons if the search string is empty, or
       // starts with a potential @ search alias or the search restriction
       // character.
       let trimmedValue = queryContext.searchString.trim();
       this._enableOrDisableOneOffSearches(
@@ -367,27 +385,27 @@ class UrlbarView {
    * @param {number} index The index of the result that has been removed.
    */
   onQueryResultRemoved(index) {
     let rowToRemove = this._rows.children[index];
     rowToRemove.remove();
 
     this._updateIndices();
 
-    if (rowToRemove != this._selected) {
+    if (rowToRemove != this._getSelectedRow()) {
       return;
     }
 
     // Select the row at the same index, if possible.
     let newSelectionIndex = index;
     if (index >= this._queryContext.results.length) {
       newSelectionIndex = this._queryContext.results.length - 1;
     }
     if (newSelectionIndex >= 0) {
-      this.selectedIndex = newSelectionIndex;
+      this.selectedRowIndex = newSelectionIndex;
     }
   }
 
   /**
    * Passes DOM events for the view to the _on_<event type> methods.
    * @param {Event} event
    *   DOM event from the <view>.
    */
@@ -832,18 +850,34 @@ class UrlbarView {
       // Reset the overflow state of elements that can overflow in case their
       // content changes while they're hidden. When making the row visible
       // again, we'll get new overflow events if needed.
       this._setElementOverflowing(row._elements.get("title"), false);
       this._setElementOverflowing(row._elements.get("url"), false);
     }
   }
 
-  _isRowVisible(row) {
-    return row.style.display != "none";
+  /**
+   * Returns true if a row or a descendant in the view is visible.
+   *
+   * @param {Element} element
+   *   A row in the view or a descendant of the row.
+   * @returns {boolean}
+   *   True if `element` or `element`'s ancestor row is visible in the view.
+   */
+  _isElementVisible(element) {
+    if (!element.classList.contains("urlbarView-row")) {
+      element = element.closest(".urlbarView-row");
+    }
+
+    if (!element) {
+      return false;
+    }
+
+    return element.style.display != "none";
   }
 
   _removeStaleRows() {
     let row = this._rows.lastElementChild;
     while (row) {
       let next = row.previousElementSibling;
       if (row.hasAttribute("stale")) {
         row.remove();
@@ -864,33 +898,155 @@ class UrlbarView {
 
   _cancelRemoveStaleRowsTimer() {
     if (this._removeStaleRowsTimer) {
       this.window.clearTimeout(this._removeStaleRowsTimer);
       this._removeStaleRowsTimer = null;
     }
   }
 
-  _selectItem(item, { updateInput = true, setAccessibleFocus = true } = {}) {
-    if (this._selected) {
-      this._selected.toggleAttribute("selected", false);
-      this._selected.removeAttribute("aria-selected");
+  _selectElement(item, { updateInput = true, setAccessibleFocus = true } = {}) {
+    if (this._selectedElement) {
+      this._selectedElement.toggleAttribute("selected", false);
+      this._selectedElement.removeAttribute("aria-selected");
     }
     if (item) {
       item.toggleAttribute("selected", true);
       item.setAttribute("aria-selected", "true");
     }
     this._setAccessibleFocus(setAccessibleFocus && item);
-    this._selected = item;
+    this._selectedElement = item;
 
     if (updateInput) {
       this.input.setValueFromResult(item && item.result);
     }
   }
 
+  /**
+   * Returns the first selectable element in the view.
+   *
+   * @returns {Element} The first selectable element in the view.
+   */
+  _getFirstSelectableElement() {
+    let firstElementChild = this._rows.firstElementChild;
+    if (
+      firstElementChild.result &&
+      firstElementChild.result.type == UrlbarUtils.RESULT_TYPE.TIP
+    ) {
+      firstElementChild = firstElementChild.querySelector(
+        ".urlbarView-tip-button"
+      );
+    }
+    return firstElementChild;
+  }
+
+  /**
+   * Returns the last selectable element in the view.
+   *
+   * @returns {Element} The last selectable element in the view.
+   */
+  _getLastSelectableElement() {
+    let lastElementChild = this._rows.lastElementChild;
+
+    // We are only interested in visible elements.
+    while (lastElementChild && !this._isElementVisible(lastElementChild)) {
+      lastElementChild = this._getPreviousSelectableElement(lastElementChild);
+    }
+
+    if (
+      lastElementChild.result &&
+      lastElementChild.result.type == UrlbarUtils.RESULT_TYPE.TIP
+    ) {
+      lastElementChild = lastElementChild.querySelector(".urlbarView-tip-help");
+    }
+
+    return lastElementChild;
+  }
+
+  /**
+   * Returns the next selectable element after the parameter `element`.
+   * @param {Element} element A selectable element in the view.
+   * @returns {Element} The next selectable element after the parameter `element`.
+   */
+  _getNextSelectableElement(element) {
+    let next;
+    if (element.classList.contains("urlbarView-tip-button")) {
+      next = element
+        .closest(".urlbarView-row")
+        .querySelector(".urlbarView-tip-help");
+    } else if (element.classList.contains("urlbarView-tip-help")) {
+      next = element.closest(".urlbarView-row").nextElementSibling;
+    } else {
+      next = element.nextElementSibling;
+    }
+
+    if (!next) {
+      return null;
+    }
+
+    if (next.result && next.result.type == UrlbarUtils.RESULT_TYPE.TIP) {
+      next = next.querySelector(".urlbarView-tip-button");
+    }
+
+    return next;
+  }
+
+  /**
+   * Returns the previous selectable element before the parameter `element`.
+   * @param {Element} element A selectable element in the view.
+   * @returns {Element} The previous selectable element before the parameter `element`.
+   */
+  _getPreviousSelectableElement(element) {
+    let previous;
+    if (element.classList.contains("urlbarView-tip-button")) {
+      previous = element.closest(".urlbarView-row").previousElementSibling;
+    } else if (element.classList.contains("urlbarView-tip-help")) {
+      previous = element
+        .closest(".urlbarView-row")
+        .querySelector(".urlbarView-tip-button");
+    } else {
+      previous = element.previousElementSibling;
+    }
+
+    if (!previous) {
+      return null;
+    }
+
+    if (
+      previous.result &&
+      previous.result.type == UrlbarUtils.RESULT_TYPE.TIP
+    ) {
+      previous = previous.querySelector(".urlbarView-tip-help");
+    }
+
+    return previous;
+  }
+
+  /**
+   * Returns the currently selected row. Useful when this._selectedElement may be a
+   * non-row element, such as a descendant element of RESULT_TYPE.TIP.
+   *
+   * @returns {Element}
+   *   The currently selected row, or ancestor row of the currently selected item.
+   *
+   */
+  _getSelectedRow() {
+    if (!this.isOpen || !this._selectedElement) {
+      return null;
+    }
+    let selected = this._selectedElement;
+
+    if (!selected.classList.contains("urlbarView-row")) {
+      // selected may be an element in a result group, like RESULT_TYPE.TIP.
+      selected = selected.closest(".urlbarView-row");
+    }
+
+    return selected;
+  }
+
   _setAccessibleFocus(item) {
     if (item) {
       this.input.inputField.setAttribute("aria-activedescendant", item.id);
     } else {
       this.input.inputField.removeAttribute("aria-activedescendant");
     }
   }
 
@@ -1028,17 +1184,17 @@ class UrlbarView {
         if (event.button == 2) {
           // Ignore right clicks.
           break;
         }
         let row = event.target;
         while (!row.classList.contains("urlbarView-row")) {
           row = row.parentNode;
         }
-        this._selectItem(row, { updateInput: false });
+        this._selectElement(row, { updateInput: false });
         this.controller.speculativeConnect(
           this.selectedResult,
           this._queryContext,
           "mousedown"
         );
         break;
     }
   }
--- a/browser/components/urlbar/tests/UrlbarTestUtils.jsm
+++ b/browser/components/urlbar/tests/UrlbarTestUtils.jsm
@@ -164,39 +164,49 @@ var UrlbarTestUtils = {
     } else if (details.type == UrlbarUtils.RESULT_TYPE.KEYWORD) {
       details.keyword = result.payload.keyword;
     }
     return details;
   },
 
   /**
    * Gets the currently selected element.
-   * @param {object} win The window containing the urlbar
-   * @returns {HtmlElement|XulElement} the selected element.
+   * @param {object} win The window containing the urlbar.
+   * @returns {HtmlElement|XulElement} The selected element.
    */
   getSelectedElement(win) {
-    return win.gURLBar.view._selected || null;
+    return win.gURLBar.view._selectedElement || null;
   },
 
   /**
-   * Gets the index of the currently selected item.
+   * Gets the currently selected row. If the selected element is a descendant of
+   * a row, this will return the ancestor row.
+   * @param {object} win The window containing the urlbar.
+   * @returns {HTMLElement|XulElement} The selected row.
+   */
+  getSelectedRow(win) {
+    return win.gURLBar.view._getSelectedRow() || null;
+  },
+
+  /**
+   * Gets the index of the currently selected element.
    * @param {object} win The window containing the urlbar.
    * @returns {number} The selected index.
    */
   getSelectedIndex(win) {
-    return win.gURLBar.view.selectedIndex;
+    return win.gURLBar.view.selectedRowIndex;
   },
 
   /**
-   * Selects the item at the index specified.
+   * Selects the element at the index specified.
    * @param {object} win The window containing the urlbar.
    * @param {index} index The index to select.
    */
   setSelectedIndex(win, index) {
-    win.gURLBar.view.selectedIndex = index;
+    win.gURLBar.view.selectedRowIndex = index;
   },
 
   /**
    * Gets the number of results.
    * You must wait for the query to be complete before using this.
    * @param {object} win The window containing the urlbar
    * @returns {number} the number of results.
    */
--- a/browser/components/urlbar/tests/browser/browser.ini
+++ b/browser/components/urlbar/tests/browser/browser.ini
@@ -109,16 +109,17 @@ support-files =
 [browser_switchToTab_closes_newtab.js]
 [browser_switchToTab_fullUrl_repeatedKeydown.js]
 [browser_switchToTabHavingURI_aOpenParams.js]
 [browser_tabMatchesInAwesomebar_perwindowpb.js]
 [browser_tabMatchesInAwesomebar.js]
 support-files =
   moz.png
 [browser_textruns.js]
+[browser_tip_keyboard_selection.js]
 [browser_urlbar_blanking.js]
 support-files =
   file_blank_but_not_blank.html
 [browser_urlbar_content_opener.js]
 [browser_urlbar_display_selectedAction_Extensions.js]
 [browser_urlbar_empty_search.js]
 [browser_urlbar_event_telemetry.js]
 support-files =
--- a/browser/components/urlbar/tests/browser/browser_autocomplete_autoselect.js
+++ b/browser/components/urlbar/tests/browser/browser_autocomplete_autoselect.js
@@ -19,17 +19,17 @@ function repeat(limit, func) {
 function assertSelected(index) {
   Assert.equal(
     UrlbarTestUtils.getSelectedIndex(window),
     index,
     "Should have selected the correct item"
   );
   // Also check the "selected" attribute, to ensure it is not a "fake" selection
   // due to binding misbehaviors.
-  let element = UrlbarTestUtils.getSelectedElement(window);
+  let element = UrlbarTestUtils.getSelectedRow(window);
   Assert.ok(
     element.hasAttribute("selected"),
     "Should have the selected attribute on the row element"
   );
 
   // This is true because although both the listbox and the one-offs can have
   // selections, the test doesn't check that.
   Assert.equal(
--- a/browser/components/urlbar/tests/browser/browser_inputHistory.js
+++ b/browser/components/urlbar/tests/browser/browser_inputHistory.js
@@ -20,17 +20,17 @@ async function bumpScore(uri, searchStri
         uri,
         gBrowser.selectedBrowser
       );
       // Look for the expected uri.
       while (gURLBar.untrimmedValue != uri) {
         EventUtils.synthesizeKey("KEY_ArrowDown");
       }
       if (useMouseClick) {
-        let element = UrlbarTestUtils.getSelectedElement(window);
+        let element = UrlbarTestUtils.getSelectedRow(window);
         EventUtils.synthesizeMouseAtCenter(element, {});
       } else {
         EventUtils.synthesizeKey("KEY_Enter");
       }
       await promise;
     }
   }
   await PlacesTestUtils.promiseAsyncUpdates();
--- a/browser/components/urlbar/tests/browser/browser_keywordBookmarklets.js
+++ b/browser/components/urlbar/tests/browser/browser_keywordBookmarklets.js
@@ -44,26 +44,26 @@ add_task(async function setup() {
       Assert.equal(result.title, "javascript:'a' ", "Check title");
       EventUtils.synthesizeKey("KEY_Enter");
       return "javascript:'a'%20";
     },
     async function() {
       await promiseAutocompleteResultPopup("bm");
       let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0);
       Assert.equal(result.title, "javascript:'' ", "Check title");
-      let element = UrlbarTestUtils.getSelectedElement(window);
+      let element = UrlbarTestUtils.getSelectedRow(window);
       EventUtils.synthesizeMouseAtCenter(element, {});
       return "javascript:''%20";
     },
     async function() {
       info("Search keyword with searchstring, then click");
       await promiseAutocompleteResultPopup("bm a");
       let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0);
       Assert.equal(result.title, "javascript:'a' ", "Check title");
-      let element = UrlbarTestUtils.getSelectedElement(window);
+      let element = UrlbarTestUtils.getSelectedRow(window);
       EventUtils.synthesizeMouseAtCenter(element, {});
       return "javascript:'a'%20";
     },
   ];
   for (let testFn of testFns) {
     await do_test(testFn);
   }
 });
--- a/browser/components/urlbar/tests/browser/browser_selectStaleResults.js
+++ b/browser/components/urlbar/tests/browser/browser_selectStaleResults.js
@@ -91,17 +91,17 @@ add_task(async function viewContainsStal
   // halfResults + 1 results (+ 1 for the heuristic).
   Assert.ok(gURLBar.controller._lastQueryContextWrapper);
   let { queryContext } = gURLBar.controller._lastQueryContextWrapper;
   Assert.ok(queryContext);
   Assert.equal(queryContext.results.length, halfResults + 1);
 
   // But there should be maxResults visible rows in the view.
   let items = Array.from(gURLBar.view._rows.children).filter(r =>
-    gURLBar.view._isRowVisible(r)
+    gURLBar.view._isElementVisible(r)
   );
   Assert.equal(items.length, maxResults);
 
   // Arrow down through all the results.  After arrowing down from the last "xx"
   // result, the stale "x" results should be selected.  We should *not* enter
   // the one-off search buttons at that point.
   for (let i = 1; i < maxResults; i++) {
     Assert.equal(UrlbarTestUtils.getSelectedIndex(window), i);
new file mode 100644
--- /dev/null
+++ b/browser/components/urlbar/tests/browser/browser_tip_keyboard_selection.js
@@ -0,0 +1,232 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+  this,
+  "UrlbarTestUtils",
+  "resource://testing-common/UrlbarTestUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+  UrlbarProvider: "resource:///modules/UrlbarUtils.jsm",
+  UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.jsm",
+  UrlbarUtils: "resource:///modules/UrlbarUtils.jsm",
+});
+
+const MEGABAR_PREF = "browser.urlbar.megabar";
+
+// Tests keyboard selection within UrlbarUtils.RESULT_TYPE.TIP results.
+
+/**
+ * A test provider.
+ */
+class TipTestProvider extends UrlbarProvider {
+  constructor(matches) {
+    super();
+    this._matches = matches;
+  }
+  get name() {
+    return "TipTestProvider";
+  }
+  get type() {
+    return UrlbarUtils.PROVIDER_TYPE.PROFILE;
+  }
+  isActive(context) {
+    return true;
+  }
+  isRestricting(context) {
+    return true;
+  }
+  async startQuery(context, addCallback) {
+    Assert.ok(true, "Tip provider was invoked");
+    this._context = context;
+    for (const match of this._matches) {
+      addCallback(this, match);
+    }
+  }
+  cancelQuery(context) {}
+}
+
+add_task(async function tipIsSecondResult() {
+  let matches = [
+    new UrlbarResult(
+      UrlbarUtils.RESULT_TYPE.URL,
+      UrlbarUtils.RESULT_SOURCE.HISTORY,
+      { url: "http://mozilla.org/a" }
+    ),
+    new UrlbarResult(
+      UrlbarUtils.RESULT_TYPE.TIP,
+      UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL,
+      {
+        icon: "",
+        text: "This is a test intervention.",
+        buttonText: "Done",
+        data: "test",
+        helpUrl:
+          "https://support.mozilla.org/en-US/kb/delete-browsing-search-download-history-firefox",
+      }
+    ),
+    new UrlbarResult(
+      UrlbarUtils.RESULT_TYPE.URL,
+      UrlbarUtils.RESULT_SOURCE.HISTORY,
+      { url: "http://mozilla.org/b" }
+    ),
+    new UrlbarResult(
+      UrlbarUtils.RESULT_TYPE.URL,
+      UrlbarUtils.RESULT_SOURCE.HISTORY,
+      { url: "http://mozilla.org/c" }
+    ),
+  ];
+
+  let provider = new TipTestProvider(matches);
+  UrlbarProvidersManager.registerProvider(provider);
+
+  gURLBar.search("test");
+  await promiseSearchComplete();
+
+  Assert.equal(
+    UrlbarTestUtils.getResultCount(window),
+    4,
+    "There should be four results in the view."
+  );
+  let secondResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 1);
+  Assert.equal(
+    secondResult.type,
+    UrlbarUtils.RESULT_TYPE.TIP,
+    "The second result should be a tip."
+  );
+
+  EventUtils.synthesizeKey("KEY_ArrowDown");
+  Assert.equal(
+    UrlbarTestUtils.getSelectedIndex(window),
+    0,
+    "The first result should be selected."
+  );
+
+  EventUtils.synthesizeKey("KEY_ArrowDown");
+  Assert.ok(
+    UrlbarTestUtils.getSelectedElement(window).classList.contains(
+      "urlbarView-tip-button"
+    ),
+    "The selected element should be the tip button."
+  );
+  Assert.equal(
+    UrlbarTestUtils.getSelectedIndex(window),
+    1,
+    "getSelectedIndex should return 1 even though the tip button is selected."
+  );
+
+  EventUtils.synthesizeKey("KEY_ArrowDown");
+  Assert.ok(
+    UrlbarTestUtils.getSelectedElement(window).classList.contains(
+      "urlbarView-tip-help"
+    ),
+    "The selected element should be the tip help button."
+  );
+  Assert.equal(
+    UrlbarTestUtils.getSelectedIndex(window),
+    1,
+    "getSelectedIndex should return 1 even though the help button is selected."
+  );
+
+  EventUtils.synthesizeKey("KEY_ArrowDown");
+  Assert.equal(
+    UrlbarTestUtils.getSelectedIndex(window),
+    2,
+    "The third result should be selected."
+  );
+
+  EventUtils.synthesizeKey("KEY_ArrowUp");
+  Assert.ok(
+    UrlbarTestUtils.getSelectedElement(window).classList.contains(
+      "urlbarView-tip-help"
+    ),
+    "The selected element should be the tip help button."
+  );
+
+  UrlbarProvidersManager.unregisterProvider(provider);
+});
+
+add_task(async function tipIsOnlyResult() {
+  let matches = [
+    new UrlbarResult(
+      UrlbarUtils.RESULT_TYPE.TIP,
+      UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL,
+      {
+        icon: "",
+        text: "This is a test intervention.",
+        buttonText: "Done",
+        data: "test",
+        helpUrl:
+          "https://support.mozilla.org/en-US/kb/delete-browsing-search-download-history-firefox",
+      }
+    ),
+  ];
+
+  // The previous test can interfere with this one if it's in the same window.
+  let newWin = await BrowserTestUtils.openNewBrowserWindow();
+
+  let provider = new TipTestProvider(matches);
+  UrlbarProvidersManager.registerProvider(provider);
+
+  gURLBar.search("test");
+  await promiseSearchComplete();
+
+  Assert.equal(
+    UrlbarTestUtils.getResultCount(window),
+    1,
+    "There should be one result in the view."
+  );
+  let firstResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 0);
+  Assert.equal(
+    firstResult.type,
+    UrlbarUtils.RESULT_TYPE.TIP,
+    "The first and only result should be a tip."
+  );
+
+  EventUtils.synthesizeKey("KEY_ArrowDown");
+  Assert.ok(
+    UrlbarTestUtils.getSelectedElement(window).classList.contains(
+      "urlbarView-tip-button"
+    ),
+    "The selected element should be the tip button."
+  );
+  Assert.equal(
+    UrlbarTestUtils.getSelectedIndex(window),
+    0,
+    "getSelectedIndex should return 0."
+  );
+
+  EventUtils.synthesizeKey("KEY_ArrowDown");
+  Assert.ok(
+    UrlbarTestUtils.getSelectedElement(window).classList.contains(
+      "urlbarView-tip-help"
+    ),
+    "The selected element should be the tip help button."
+  );
+  Assert.equal(
+    UrlbarTestUtils.getSelectedIndex(window),
+    0,
+    "getSelectedIndex should return 0."
+  );
+
+  EventUtils.synthesizeKey("KEY_ArrowDown");
+  Assert.equal(
+    UrlbarTestUtils.getSelectedIndex(window),
+    -1,
+    "There should be no selection."
+  );
+
+  EventUtils.synthesizeKey("KEY_ArrowUp");
+  Assert.ok(
+    UrlbarTestUtils.getSelectedElement(window).classList.contains(
+      "urlbarView-tip-help"
+    ),
+    "The selected element should be the tip help button."
+  );
+
+  UrlbarProvidersManager.unregisterProvider(provider);
+  await BrowserTestUtils.closeWindow(newWin);
+});
--- a/browser/components/urlbar/tests/browser/browser_urlbar_event_telemetry.js
+++ b/browser/components/urlbar/tests/browser/browser_urlbar_event_telemetry.js
@@ -182,17 +182,17 @@ const tests = [
   async function() {
     info("Type something, click on bookmark entry.");
     gURLBar.select();
     let promise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
     await promiseAutocompleteResultPopup("exa", window, true);
     while (gURLBar.untrimmedValue != "http://example.com/?q=%s") {
       EventUtils.synthesizeKey("KEY_ArrowDown");
     }
-    let element = UrlbarTestUtils.getSelectedElement(window);
+    let element = UrlbarTestUtils.getSelectedRow(window);
     EventUtils.synthesizeMouseAtCenter(element, {});
     await promise;
     return {
       category: "urlbar",
       method: "engagement",
       object: "click",
       value: "typed",
       extra: {
@@ -363,17 +363,17 @@ const tests = [
     gURLBar.select();
     let promise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
     await UrlbarTestUtils.promisePopupOpen(window, () => {
       EventUtils.synthesizeKey("KEY_ArrowDown", {});
     });
     while (gURLBar.untrimmedValue != "http://mochi.test:8888/") {
       EventUtils.synthesizeKey("KEY_ArrowDown");
     }
-    let element = UrlbarTestUtils.getSelectedElement(window);
+    let element = UrlbarTestUtils.getSelectedRow(window);
     EventUtils.synthesizeMouseAtCenter(element, {});
     await promise;
     return {
       category: "urlbar",
       method: "engagement",
       object: "click",
       value: "topsites",
       extra: {
@@ -454,17 +454,17 @@ const tests = [
     Services.prefs.setBoolPref("browser.urlbar.openViewOnFocus", true);
     await UrlbarTestUtils.promisePopupOpen(window, () => {
       window.document.getElementById("Browser:OpenLocation").doCommand();
     });
     Services.prefs.clearUserPref("browser.urlbar.openViewOnFocus");
     while (gURLBar.untrimmedValue != "http://mochi.test:8888/") {
       EventUtils.synthesizeKey("KEY_ArrowDown");
     }
-    let element = UrlbarTestUtils.getSelectedElement(window);
+    let element = UrlbarTestUtils.getSelectedRow(window);
     EventUtils.synthesizeMouseAtCenter(element, {});
     await promise;
     return {
       category: "urlbar",
       method: "engagement",
       object: "click",
       value: "topsites",
       extra: {
@@ -513,17 +513,17 @@ const tests = [
     gURLBar.setAttribute("pageproxystate", "invalid");
     let promise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
     Services.prefs.setBoolPref("browser.urlbar.openViewOnFocus", true);
     await UrlbarTestUtils.promisePopupOpen(window, () => {
       window.document.getElementById("Browser:OpenLocation").doCommand();
     });
     Services.prefs.clearUserPref("browser.urlbar.openViewOnFocus");
     await UrlbarTestUtils.promiseSearchComplete(window);
-    let element = UrlbarTestUtils.getSelectedElement(window);
+    let element = UrlbarTestUtils.getSelectedRow(window);
     EventUtils.synthesizeMouseAtCenter(element, {});
     await promise;
     return {
       category: "urlbar",
       method: "engagement",
       object: "click",
       value: "typed",
       extra: {
--- a/browser/components/urlbar/tests/browser/browser_urlbar_speculative_connect.js
+++ b/browser/components/urlbar/tests/browser/browser_urlbar_speculative_connect.js
@@ -101,17 +101,17 @@ add_task(async function popup_mousedown_
       details.url,
       completeValue,
       "The second item has the url we visited."
     );
 
     info("Clicking on the second result");
     EventUtils.synthesizeMouseAtCenter(listitem, { type: "mousedown" }, window);
     Assert.equal(
-      UrlbarTestUtils.getSelectedElement(window),
+      UrlbarTestUtils.getSelectedRow(window),
       listitem,
       "The second item is selected"
     );
     await UrlbarTestUtils.promiseSpeculativeConnections(
       server,
       connectionNumber + 1
     );
   });
@@ -173,16 +173,16 @@ add_task(async function test_autofill_pr
 
 add_task(async function test_no_heuristic_result() {
   info("Don't speculative connect on results addition if there's no heuristic");
   await withHttpServer(serverInfo, async server => {
     let connectionNumber = server.connectionNumber;
     info(`Searching for the empty string`);
     await promiseAutocompleteResultPopup("", window, true);
     ok(UrlbarTestUtils.getResultCount(window) > 0, "Has results");
-    let result = await UrlbarTestUtils.getSelectedElement(window);
+    let result = await UrlbarTestUtils.getSelectedRow(window);
     Assert.strictEqual(result, null, `Should have no selection`);
     await UrlbarTestUtils.promiseSpeculativeConnections(
       server,
       connectionNumber
     );
   });
 });
--- a/browser/components/urlbar/tests/browser/browser_urlbar_speculative_connect_not_with_client_cert.js
+++ b/browser/components/urlbar/tests/browser/browser_urlbar_speculative_connect_not_with_client_cert.js
@@ -189,17 +189,17 @@ add_task(
     let listitem = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1);
     let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 1);
     info(`The url of the second item is ${details.url}`);
     is(details.url, completeValue, "The second item has the url we visited.");
 
     expectingChooseCertificate = false;
     EventUtils.synthesizeMouseAtCenter(listitem, { type: "mousedown" }, window);
     is(
-      UrlbarTestUtils.getSelectedElement(window),
+      UrlbarTestUtils.getSelectedRow(window),
       listitem,
       "The second item is selected"
     );
 
     // We shouldn't have triggered a speculative connection, because a client
     // certificate is installed.
     SimpleTest.requestFlakyTimeout("Wait for UI");
     await new Promise(resolve => setTimeout(resolve, 200));
--- a/browser/themes/shared/urlbar-autocomplete.inc.css
+++ b/browser/themes/shared/urlbar-autocomplete.inc.css
@@ -199,29 +199,25 @@
   background-size: 16px;
   background-position: center;
   background-repeat: no-repeat;
   padding-inline: 8px;
   margin-inline-start: 8px;
   -moz-context-properties: fill, fill-opacity;
 }
 
+.urlbarView-tip-help[selected],
 .urlbarView-tip-help:hover {
   background-color: var(--in-content-button-background-hover);
 }
 
 .urlbarView-tip-help:hover:active {
   background-color: var(--in-content-button-background-active);
 }
 
-.urlbarView-tip-help:-moz-focusring,
-.urlbarView-tip-button:-moz-focusring {
-  box-shadow: 0 0 0 1px #0a84ff inset, 0 0 0 1px #0a84ff, 0 0 0 4px rgba(10, 132, 255, 0.3);
-}
-
 #urlbar-contextual-tip-icon {
   min-width: 24px;
   height: 24px;
   margin-inline-end: @urlbarViewIconMarginEnd@;
   background-repeat: no-repeat;
   background-size: contain;
   -moz-context-properties: fill, fill-opacity;
 }
@@ -270,31 +266,31 @@
   border-radius: 2px;
   font-size: 0.93em;
   color: inherit;
   background-color: var(--in-content-button-background);
   min-width: 10em;
   flex-shrink: 0;
 }
 
+.urlbarView-tip-button[selected] {
+  color: white;
+  background-color: var(--in-content-primary-button-background);
+  box-shadow: 0 0 0 1px #0a84ff inset, 0 0 0 1px #0a84ff, 0 0 0 4px rgba(10, 132, 255, 0.3);
+}
 .urlbarView-tip-button:hover {
   color: white;
   background-color: var(--in-content-primary-button-background-hover);
 }
 
 .urlbarView-tip-button:hover:active {
   color: white;
   background-color: var(--in-content-primary-button-background-active);
 }
 
-.urlbarView-tip-button:-moz-focusring {
- color: white:
- background-color: var(--in-content-primary-button-background);
-}
-
 .urlbarView-tip-button-spacer {
   flex: 1;
   min-width: 75px;
 }
 
 .urlbarView-title {
   overflow: hidden;
   white-space: nowrap;