Bug 1182338 - Bring in-content search UI keyboard navigation up to parity with main searchbar UI. r=adw
authorNihanth Subramanya <nhnt11@gmail.com>
Fri, 10 Jul 2015 16:19:02 -0700
changeset 287587 b5cd383beb2a45d783b1fcdeda9b47d74b45a0a5
parent 287586 a78d4180daf51279ab16e1d9789b695c20dbf27a
child 287588 b7144dca1caca54f0338b59cc372116a26e94115
push id5067
push userraliiev@mozilla.com
push dateMon, 21 Sep 2015 14:04:52 +0000
treeherdermozilla-beta@14221ffe5b2f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersadw
bugs1182338
milestone42.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 1182338 - Bring in-content search UI keyboard navigation up to parity with main searchbar UI. r=adw
browser/base/content/contentSearchUI.js
--- a/browser/base/content/contentSearchUI.js
+++ b/browser/base/content/contentSearchUI.js
@@ -91,20 +91,16 @@ ContentSearchUIController.prototype = {
   },
 
   get engines() {
     return this._engines;
   },
 
   set engines(val) {
     this._engines = val;
-    if (!this._table.hidden) {
-      this._setUpOneOffButtons();
-      return;
-    }
     this._pendingOneOffRefresh = true;
   },
 
   // The selectedIndex is the index of the element with the "selected" class in
   // the list obtained by concatenating the suggestion rows, one-off buttons, and
   // search settings button.
   get selectedIndex() {
     let allElts = [...this._suggestionsList.children,
@@ -122,34 +118,64 @@ ContentSearchUIController.prototype = {
   set selectedIndex(idx) {
     // Update the table's rows, and the input when there is a selection.
     this._table.removeAttribute("aria-activedescendant");
     this.input.removeAttribute("aria-activedescendant");
 
     let allElts = [...this._suggestionsList.children,
                    ...this._oneOffButtons,
                    document.getElementById("contentSearchSettingsButton")];
+    // If we are selecting a suggestion and a one-off is selected, don't deselect it.
+    let excludeIndex = idx < this.numSuggestions && this.selectedButtonIndex > -1 ?
+                       this.numSuggestions + this.selectedButtonIndex : -1;
     for (let i = 0; i < allElts.length; ++i) {
       let elt = allElts[i];
       let ariaSelectedElt = i < this.numSuggestions ? elt.firstChild : elt;
       if (i == idx) {
         elt.classList.add("selected");
         ariaSelectedElt.setAttribute("aria-selected", "true");
         this.input.setAttribute("aria-activedescendant", ariaSelectedElt.id);
       }
-      else {
+      else if (i != excludeIndex) {
         elt.classList.remove("selected");
         ariaSelectedElt.setAttribute("aria-selected", "false");
       }
     }
   },
 
+  get selectedButtonIndex() {
+    let elts = [...this._oneOffButtons,
+                document.getElementById("contentSearchSettingsButton")];
+    for (let i = 0; i < elts.length; ++i) {
+      if (elts[i].classList.contains("selected")) {
+        return i;
+      }
+    }
+    return -1;
+  },
+
+  set selectedButtonIndex(idx) {
+    let elts = [...this._oneOffButtons,
+                document.getElementById("contentSearchSettingsButton")];
+    for (let i = 0; i < elts.length; ++i) {
+      let elt = elts[i];
+      if (i == idx) {
+        elt.classList.add("selected");
+        elt.setAttribute("aria-selected", "true");
+      }
+      else {
+        elt.classList.remove("selected");
+        elt.setAttribute("aria-selected", "false");
+      }
+    }
+  },
+
   get selectedEngineName() {
-    let selectedElt = this._table.querySelector(".selected");
-    if (selectedElt && selectedElt.engineName) {
+    let selectedElt = this._oneOffsTable.querySelector(".selected");
+    if (selectedElt) {
       return selectedElt.engineName;
     }
     return this.defaultEngine.name;
   },
 
   get numSuggestions() {
     return this._suggestionsList.children.length;
   },
@@ -189,17 +215,17 @@ ContentSearchUIController.prototype = {
     this._sendMsg("AddFormHistoryEntry", this.input.value);
   },
 
   handleEvent: function (event) {
     this["_on" + event.type[0].toUpperCase() + event.type.substr(1)](event);
   },
 
   _onCommand: function(aEvent) {
-    if (this.selectedIndex == this.numSuggestions + this._oneOffButtons.length) {
+    if (this.selectedButtonIndex == this._oneOffButtons.length) {
       // Settings button was selected.
       this._sendMsg("ManageEngines");
       return;
     }
 
     this.search(aEvent);
 
     if (aEvent) {
@@ -259,29 +285,68 @@ ContentSearchUIController.prototype = {
       this._getSuggestions();
       this.selectAndUpdateInput(-1);
     }
     this._updateSearchWithHeader();
   },
 
   _onKeypress: function (event) {
     let selectedIndexDelta = 0;
+    let selectedSuggestionDelta = 0;
+    let selectedOneOffDelta = 0;
+
     switch (event.keyCode) {
     case event.DOM_VK_UP:
-      if (!this._table.hidden) {
-        selectedIndexDelta = -1;
+      if (this._table.hidden) {
+        return;
       }
+      if (event.getModifierState("Accel")) {
+        if (event.shiftKey) {
+          selectedSuggestionDelta = -1;
+          break;
+        }
+        this._cycleCurrentEngine(true);
+        break;
+      }
+      if (event.altKey) {
+        selectedOneOffDelta = -1;
+        break;
+      }
+      selectedIndexDelta = -1;
       break;
     case event.DOM_VK_DOWN:
       if (this._table.hidden) {
         this._getSuggestions();
+        return;
       }
-      else {
-        selectedIndexDelta = 1;
+      if (event.getModifierState("Accel")) {
+        if (event.shiftKey) {
+          selectedSuggestionDelta = 1;
+          break;
+        }
+        this._cycleCurrentEngine(false);
+        break;
+      }
+      if (event.altKey) {
+        selectedOneOffDelta = 1;
+        break;
       }
+      selectedIndexDelta = 1;
+      break;
+    case event.DOM_VK_TAB:
+      if (this._table.hidden) {
+        return;
+      }
+      // Shift+tab when either the first or no one-off is selected, as well as
+      // tab when the settings button is selected, should change focus as normal.
+      if ((this.selectedButtonIndex <= 0 && event.shiftKey) ||
+          this.selectedButtonIndex == this._oneOffButtons.length && !event.shiftKey) {
+        return;
+      }
+      selectedOneOffDelta = event.shiftKey ? -1 : 1;
       break;
     case event.DOM_VK_RIGHT:
       // Allow normal caret movement until the caret is at the end of the input.
       if (this.input.selectionStart != this.input.selectionEnd ||
           this.input.selectionEnd != this.input.value.length) {
         return;
       }
       if (this.numSuggestions && this.selectedIndex >= 0 &&
@@ -292,47 +357,107 @@ ContentSearchUIController.prototype = {
       } else {
         // If we didn't select anything, make sure to remove the attributes
         // in case they were populated last time.
         this.input.removeAttribute("selection-index");
         this.input.removeAttribute("selection-kind");
       }
       this._stickyInputValue = this.input.value;
       this._hideSuggestions();
-      break;
+      return;
     case event.DOM_VK_RETURN:
       this._onCommand(event);
-      break;
+      return;
     case event.DOM_VK_DELETE:
       if (this.selectedIndex >= 0) {
         this.deleteSuggestionAtIndex(this.selectedIndex);
       }
-      break;
+      return;
     case event.DOM_VK_ESCAPE:
       if (!this._table.hidden) {
         this._hideSuggestions();
       }
+      return;
     default:
       return;
     }
 
+    let currentIndex = this.selectedIndex;
     if (selectedIndexDelta) {
-      // Update the selection.
-      let newSelectedIndex = this.selectedIndex + selectedIndexDelta;
+      let newSelectedIndex = currentIndex + selectedIndexDelta;
       if (newSelectedIndex < -1) {
         newSelectedIndex = this.numSuggestions + this._oneOffButtons.length;
       }
-      else if (this.numSuggestions + this._oneOffButtons.length < newSelectedIndex) {
+      // If are moving up from the first one off, we have to deselect the one off
+      // manually because the selectedIndex setter tries to exclude the selected
+      // one-off (which is desirable for accel+shift+up/down).
+      if (currentIndex == this.numSuggestions && selectedIndexDelta == -1) {
+        this.selectedButtonIndex = -1;
+      }
+      this.selectAndUpdateInput(newSelectedIndex);
+    }
+
+    else if (selectedSuggestionDelta) {
+      let newSelectedIndex;
+      if (currentIndex >= this.numSuggestions || currentIndex == -1) {
+        // No suggestion already selected, select the first/last one appropriately.
+        newSelectedIndex = selectedSuggestionDelta == 1 ?
+                           0 : this.numSuggestions - 1;
+      }
+      else {
+        newSelectedIndex = currentIndex + selectedSuggestionDelta;
+      }
+      if (newSelectedIndex >= this.numSuggestions) {
         newSelectedIndex = -1;
       }
       this.selectAndUpdateInput(newSelectedIndex);
+    }
 
-      // Prevent the input's caret from moving.
-      event.preventDefault();
+    else if (selectedOneOffDelta) {
+      let newSelectedIndex;
+      let currentButton = this.selectedButtonIndex;
+      if (currentButton == -1 || currentButton == this._oneOffButtons.length) {
+        // No one-off already selected, select the first/last one appropriately.
+        newSelectedIndex = selectedOneOffDelta == 1 ?
+                           0 : this._oneOffButtons.length - 1;
+      }
+      else {
+        newSelectedIndex = currentButton + selectedOneOffDelta;
+      }
+      // Allow selection of the settings button via the tab key.
+      if (newSelectedIndex == this._oneOffButtons.length &&
+          event.keyCode != event.DOM_VK_TAB) {
+        newSelectedIndex = -1;
+      }
+      this.selectedButtonIndex = newSelectedIndex;
     }
+
+    // Prevent the input's caret from moving.
+    event.preventDefault();
+  },
+
+  _currentEngineIndex: -1,
+  _cycleCurrentEngine: function (aReverse) {
+    if ((this._currentEngineIndex == this._oneOffButtons.length - 1 && !aReverse) ||
+        (this._currentEngineIndex < 0 && aReverse)) {
+      return;
+    }
+    this._currentEngineIndex += aReverse ? -1 : 1;
+    let engine;
+    if (this._currentEngineIndex == -1) {
+      engine = this._originalDefaultEngine;
+    } else {
+      let button = this._oneOffButtons[this._currentEngineIndex];
+      engine = {
+        name: button.engineName,
+        icon: button.firstChild.getAttribute("src"),
+      };
+    }
+    this._sendMsg("SetCurrentEngine", engine.name);
+    this.defaultEngine = engine;
   },
 
   _onFocus: function () {
     if (this._mousedown) {
       return;
     }
     // When the input box loses focus to something in our table, we refocus it
     // immediately. This causes the focus highlight to flicker, so we set a
@@ -351,26 +476,40 @@ ContentSearchUIController.prototype = {
       setTimeout(() => this.input.focus(), 0);
       return;
     }
     this.input.removeAttribute("keepfocus");
     this._hideSuggestions();
   },
 
   _onMousemove: function (event) {
-    this.selectedIndex = this._indexOfTableItem(event.target);
+    let idx = this._indexOfTableItem(event.target);
+    if (idx >= this.numSuggestions) {
+      this.selectedButtonIndex = idx - this.numSuggestions;
+      return;
+    }
+    this.selectedIndex = idx;
   },
 
   _onMouseup: function (event) {
     if (event.button == 2) {
       return;
     }
     this._onCommand(event);
   },
 
+  _onMouseout: function (event) {
+    // We only deselect one-off buttons and the settings button when they are
+    // moused out.
+    let idx = this._indexOfTableItem(event.originalTarget);
+    if (idx >= this.numSuggestions) {
+      this.selectedButtonIndex = -1;
+    }
+  },
+
   _onClick: function (event) {
     this._onMouseup(event);
   },
 
   _onContentSearchService: function (event) {
     let methodName = "_onMsg" + event.detail.type;
     if (methodName in this) {
       this[methodName](event.detail.data);
@@ -422,16 +561,20 @@ ContentSearchUIController.prototype = {
     if (this._table.hidden) {
       this.selectedIndex = -1;
       if (this._pendingOneOffRefresh) {
         this._setUpOneOffButtons();
         delete this._pendingOneOffRefresh;
       }
       this._table.hidden = false;
       this.input.setAttribute("aria-expanded", "true");
+      this._originalDefaultEngine = {
+        name: this.defaultEngine.name,
+        icon: this.defaultEngine.icon,
+      };
     }
   },
 
   _onMsgState: function (state) {
     this.defaultEngine = {
       name: state.currentEngine.name,
       icon: this._getFaviconURIFromBuffer(state.currentEngine.iconBuffer),
     };
@@ -442,20 +585,16 @@ ContentSearchUIController.prototype = {
     this._onMsgState(state);
   },
 
   _onMsgCurrentEngine: function (engine) {
     this.defaultEngine = {
       name: engine.name,
       icon: this._getFaviconURIFromBuffer(engine.iconBuffer),
     };
-    if (!this._table.hidden) {
-      this._setUpOneOffButtons();
-      return;
-    }
     this._pendingOneOffRefresh = true;
   },
 
   _onMsgStrings: function (strings) {
     this._strings = strings;
     this._updateDefaultEngineHeader();
     this._updateSearchWithHeader();
     document.getElementById("contentSearchSettingsButton").textContent =
@@ -567,16 +706,19 @@ ContentSearchUIController.prototype = {
   _clearSuggestionRows: function() {
     while (this._suggestionsList.firstElementChild) {
       this._suggestionsList.firstElementChild.remove();
     }
   },
 
   _hideSuggestions: function () {
     this.input.setAttribute("aria-expanded", "false");
+    this.selectedIndex = -1;
+    this.selectedButtonIndex = -1;
+    this._currentEngineIndex = -1;
     this._table.hidden = true;
   },
 
   _indexOfTableItem: function (elt) {
     if (elt.classList.contains("contentSearchOneOffItem")) {
       return this.numSuggestions + this._oneOffButtons.indexOf(elt);
     }
     if (elt.classList.contains("contentSearchSettingsButton")) {
@@ -600,21 +742,17 @@ ContentSearchUIController.prototype = {
 
     // When the search input box loses focus, we want to immediately give focus
     // back to it if the blur was because the user clicked somewhere in the table.
     // onBlur uses the _mousedown flag to detect this.
     this._table.addEventListener("mousedown", () => { this._mousedown = true; });
     document.addEventListener("mouseup", () => { delete this._mousedown; });
 
     // Deselect the selected element on mouseout if it wasn't a suggestion.
-    this._table.addEventListener("mouseout", () => {
-      if (this.selectedIndex >= this.numSuggestions) {
-        this.selectAndUpdateInput(-1);
-      }
-    });
+    this._table.addEventListener("mouseout", this);
 
     // If a search is loaded in the same tab, ensure the suggestions dropdown
     // is hidden immediately when the page starts loading and not when it first
     // appears, in order to provide timely feedback to the user.
     window.addEventListener("beforeunload", () => { this._hideSuggestions(); });
 
     let headerRow = document.createElementNS(HTML_NS, "tr");
     let header = document.createElementNS(HTML_NS, "td");