Bug 1309935 - Add ability to find within select dropdown when over 40 elements. r?jaws, mconley, enndeakin draft
authorTyler Maklebust <maklebus@msu.edu>
Mon, 26 Dec 2016 10:50:28 -0500
changeset 453921 fec15f0d5be6840b7e5007fb239039b20756c66e
parent 451501 5206da513654c0e0b36293c9ce149ef9ed907a41
child 540557 00aabcb0ce27438525d743e8b1b63c41f02ab4d4
push id39767
push userbmo:maklebus@msu.edu
push dateMon, 26 Dec 2016 16:07:28 +0000
reviewersjaws, mconley, enndeakin
bugs1309935
milestone53.0a1
Bug 1309935 - Add ability to find within select dropdown when over 40 elements. r?jaws, mconley, enndeakin Search implemented for select dropdown options. List navigation takes keyboard input as before, until search field is focused. Pref added to enable search (dom.forms.selectSearch). Task added to test search functionality. MozReview-Commit-ID: BiKRvNbQnxx
browser/base/content/browser.xul
browser/base/content/test/general/browser_selectpopup.js
layout/xul/nsMenuPopupFrame.cpp
layout/xul/nsXULPopupManager.cpp
layout/xul/nsXULPopupManager.h
modules/libpref/init/all.js
toolkit/modules/SelectParentHelper.jsm
--- a/browser/base/content/browser.xul
+++ b/browser/base/content/browser.xul
@@ -172,16 +172,17 @@
 
     <!-- for select dropdowns. The menupopup is what shows the list of options,
          and the popuponly menulist makes things like the menuactive attributes
          work correctly on the menupopup. ContentSelectDropdown expects the
          popuponly menulist to be its immediate parent. -->
     <menulist popuponly="true" id="ContentSelectDropdown" hidden="true">
       <menupopup rolluponmousewheel="true"
                  activateontab="true" position="after_start"
+                 level="parent"
 #ifdef XP_WIN
                  consumeoutsideclicks="false" ignorekeys="shortcuts"
 #endif
         />
     </menulist>
 
     <!-- for invalid form error message -->
     <panel id="invalid-form-popup" type="arrow" orient="vertical" noautofocus="true" hidden="true" level="parent">
--- a/browser/base/content/test/general/browser_selectpopup.js
+++ b/browser/base/content/test/general/browser_selectpopup.js
@@ -36,16 +36,31 @@ const PAGECONTENT_SMALL =
   "</select><select id='two'>" +
   "  <option value='Three'>Three</option>" +
   "  <option value='Four'>Four</option>" +
   "</select><select id='three'>" +
   "  <option value='Five'>Five</option>" +
   "  <option value='Six'>Six</option>" +
   "</select></body></html>";
 
+const PAGECONTENT_GROUPS =
+  "<html>" +
+  "<body><select id='one'>" +
+  "  <optgroup label='Group 1'>" +
+  "    <option value='G1 O1'>G1 O1</option>" +
+  "    <option value='G1 O2'>G1 O2</option>" +
+  "    <option value='G1 O3'>G1 O3</option>" +
+  "  </optgroup>" +
+  "  <optgroup label='Group 2'>" +
+  "    <option value='G2 O1'>G2 O4</option>" +
+  "    <option value='G2 O2'>G2 O5</option>" +
+  "    <option value='Hidden' style='display: none;'>Hidden</option>" +
+  "  </optgroup>" +
+  "</select></body></html>";
+
 const PAGECONTENT_SOMEHIDDEN =
   "<html><head><style>.hidden { display: none; }</style></head>" +
   "<body><select id='one'>" +
   "  <option value='One' style='display: none;'>OneHidden</option>" +
   "  <option value='Two' class='hidden'>TwoHidden</option>" +
   "  <option value='Three'>ThreeVisible</option>" +
   "  <option value='Four'style='display: table;'>FourVisible</option>" +
   "  <option value='Five'>FiveVisible</option>" +
@@ -437,18 +452,25 @@ function* performLargePopupTests(win)
     let rect = selectPopup.getBoundingClientRect();
     ok(rect.top >= browserRect.top, "Popup top position in within browser area");
     ok(rect.bottom <= browserRect.bottom, "Popup bottom position in within browser area");
 
     // Don't check the scroll position for the last step as the popup will be cut off.
     if (positions.length > 0) {
       let cs = win.getComputedStyle(selectPopup);
       let bpBottom = parseFloat(cs.paddingBottom) + parseFloat(cs.borderBottomWidth);
+      let selectedOption = 60;
 
-      is(selectPopup.childNodes[60].getBoundingClientRect().bottom,
+      if (Services.prefs.getBoolPref("dom.forms.selectSearch")) {
+        // Use option 61 instead of 60, as the 60th option element is actually the
+        // 61st child, since the first child is now the search input field.
+        selectedOption = 61;
+      }
+
+      is(selectPopup.childNodes[selectedOption].getBoundingClientRect().bottom,
          selectPopup.getBoundingClientRect().bottom - bpBottom,
          "Popup scroll at correct position " + bpBottom);
     }
 
     yield hideSelectPopup(selectPopup, "enter", win);
 
     position = positions.shift();
     if (!position) {
@@ -459,16 +481,89 @@ function* performLargePopupTests(win)
     yield ContentTask.spawn(browser, position, function*(contentPosition) {
       let select = content.document.getElementById("one");
       select.setAttribute("style", contentPosition);
     });
     yield contentPainted;
   }
 }
 
+function* performSelectSearchTests(win)
+{
+  let browser = win.gBrowser.selectedBrowser;
+  yield ContentTask.spawn(browser, null, function*() {
+    let doc = content.document;
+    let select = doc.getElementById("one");
+
+    for (var i = 0; i < 40; i++) {
+      select.add(new content.Option("Test" + i));
+    }
+
+    select.options[1].selected = true;
+    select.focus();
+  });
+
+  let selectPopup = win.document.getElementById("ContentSelectDropdown").menupopup;
+  yield openSelectPopup(selectPopup, false, "select", win);
+
+  let searchElement = selectPopup.querySelector("textbox");
+  searchElement.focus();
+
+  EventUtils.synthesizeKey("O", {}, win);
+  is(selectPopup.childNodes[2].hidden, false, "First option should be visible");
+  is(selectPopup.childNodes[3].hidden, false, "Second option should be visible");
+
+  EventUtils.synthesizeKey("3", {}, win);
+  is(selectPopup.childNodes[2].hidden, true, "First option should be hidden");
+  is(selectPopup.childNodes[3].hidden, true, "Second option should be hidden");
+  is(selectPopup.childNodes[4].hidden, false, "Third option should be visible");
+
+  EventUtils.synthesizeKey("Z", {}, win);
+  is(selectPopup.childNodes[4].hidden, true, "Third option should be hidden");
+  is(selectPopup.childNodes[1].hidden, true, "First group header should be hidden");
+
+  EventUtils.synthesizeKey("VK_BACK_SPACE", {}, win);
+  is(selectPopup.childNodes[4].hidden, false, "Third option should be visible");
+
+  EventUtils.synthesizeKey("VK_BACK_SPACE", {}, win);
+  is(selectPopup.childNodes[5].hidden, false, "Second group header should be visible");
+
+  EventUtils.synthesizeKey("VK_BACK_SPACE", {}, win);
+  EventUtils.synthesizeKey("O", {}, win);
+  EventUtils.synthesizeKey("5", {}, win);
+  is(selectPopup.childNodes[5].hidden, false, "Second group header should be visible");
+  is(selectPopup.childNodes[1].hidden, true, "First group header should be hidden");
+
+  EventUtils.synthesizeKey("VK_BACK_SPACE", {}, win);
+  is(selectPopup.childNodes[1].hidden, false, "First group header should be shown");
+
+  EventUtils.synthesizeKey("VK_BACK_SPACE", {}, win);
+  is(selectPopup.childNodes[8].hidden, true, "Option hidden by content should remain hidden");
+
+  yield hideSelectPopup(selectPopup, "escape", win);
+}
+
+// This test checks the functionality of search in select elements with groups
+// and a large number of options.
+add_task(function* test_select_search() {
+  yield SpecialPowers.pushPrefEnv({
+    set: [
+      ["dom.forms.selectSearch", true],
+    ],
+  });
+  const pageUrl = "data:text/html," + escape(PAGECONTENT_GROUPS);
+  let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, pageUrl);
+
+  yield performSelectSearchTests(window);
+
+  yield BrowserTestUtils.removeTab(tab);
+
+  yield SpecialPowers.popPrefEnv();
+});
+
 // This test checks select elements with a large number of options to ensure that
 // the popup appears within the browser area.
 add_task(function* test_large_popup() {
   const pageUrl = "data:text/html," + escape(PAGECONTENT_SMALL);
   let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, pageUrl);
 
   yield* performLargePopupTests(window);
 
--- a/layout/xul/nsMenuPopupFrame.cpp
+++ b/layout/xul/nsMenuPopupFrame.cpp
@@ -2222,16 +2222,23 @@ nsMenuPopupFrame::AttributeChanged(int32
       if (widget) {
         nsAutoString title;
         mContent->GetAttr(kNameSpaceID_None, nsGkAtoms::label, title);
         if (!title.IsEmpty()) {
           widget->SetTitle(title);
         }
       }
     }
+  } else if (aAttribute == nsGkAtoms::ignorekeys) {
+    nsXULPopupManager* pm = nsXULPopupManager::GetInstance();
+    if (pm) {
+      nsAutoString ignorekeys;
+      mContent->GetAttr(kNameSpaceID_None, nsGkAtoms::ignorekeys, ignorekeys);
+      pm->UpdateIgnoreKeys(ignorekeys.EqualsLiteral("true"));
+    }
   }
 
   return rv;
 }
 
 void
 nsMenuPopupFrame::MoveToAttributePosition()
 {
--- a/layout/xul/nsXULPopupManager.cpp
+++ b/layout/xul/nsXULPopupManager.cpp
@@ -2618,16 +2618,27 @@ nsXULPopupManager::HandleEvent(nsIDOMEve
   }
 
   NS_ABORT();
 
   return NS_OK;
 }
 
 nsresult
+nsXULPopupManager::UpdateIgnoreKeys(bool aIgnoreKeys)
+{
+  nsMenuChainItem* item = GetTopVisibleMenu();
+  if (item) {
+    item->SetIgnoreKeys(aIgnoreKeys ? eIgnoreKeys_True : eIgnoreKeys_Shortcuts);
+  }
+  UpdateKeyboardListeners();
+  return NS_OK;
+}
+
+nsresult
 nsXULPopupManager::KeyUp(nsIDOMKeyEvent* aKeyEvent)
 {
   // don't do anything if a menu isn't open or a menubar isn't active
   if (!mActiveMenuBar) {
     nsMenuChainItem* item = GetTopVisibleMenu();
     if (!item || item->PopupType() != ePopupTypeMenu)
       return NS_OK;
 
--- a/layout/xul/nsXULPopupManager.h
+++ b/layout/xul/nsXULPopupManager.h
@@ -658,16 +658,19 @@ public:
 
   /**
    * Handles the keyboard event with keyCode value. Returns true if the event
    * has been handled.
    */
   bool HandleKeyboardEventWithKeyCode(nsIDOMKeyEvent* aKeyEvent,
                                       nsMenuChainItem* aTopVisibleMenuItem);
 
+  // Sets mIgnoreKeys of the Top Visible Menu Item
+  nsresult UpdateIgnoreKeys(bool aIgnoreKeys);
+
   nsresult KeyUp(nsIDOMKeyEvent* aKeyEvent);
   nsresult KeyDown(nsIDOMKeyEvent* aKeyEvent);
   nsresult KeyPress(nsIDOMKeyEvent* aKeyEvent);
 
 protected:
   nsXULPopupManager();
   ~nsXULPopupManager();
 
--- a/modules/libpref/init/all.js
+++ b/modules/libpref/init/all.js
@@ -1183,16 +1183,19 @@ pref("dom.forms.datetime", false);
 pref("dom.forms.datetime.timepicker", false);
 
 // Support for new @autocomplete values
 pref("dom.forms.autocomplete.experimental", false);
 
 // Enables requestAutocomplete DOM API on forms.
 pref("dom.forms.requestAutocomplete", false);
 
+// Enable search in <select> dropdowns (more than 40 options)
+pref("dom.forms.selectSearch", false);
+
 // Enable Directory API. By default, disabled.
 pref("dom.input.dirpicker", false);
 
 // Enables system messages and activities
 pref("dom.sysmsg.enabled", false);
 
 // Enable pre-installed applications.
 pref("dom.webapps.useCurrentProfile", false);
--- a/toolkit/modules/SelectParentHelper.jsm
+++ b/toolkit/modules/SelectParentHelper.jsm
@@ -5,20 +5,24 @@
 "use strict";
 
 this.EXPORTED_SYMBOLS = [
   "SelectParentHelper"
 ];
 
 const {utils: Cu} = Components;
 const {AppConstants} = Cu.import("resource://gre/modules/AppConstants.jsm");
+const {Services} = Cu.import("resource://gre/modules/Services.jsm");
 
 // Maximum number of rows to display in the select dropdown.
 const MAX_ROWS = 20;
 
+// Minimum elements required to show select search
+const SEARCH_MINIMUM_ELEMENTS = 40;
+
 var currentBrowser = null;
 var currentMenulist = null;
 var currentZoom = 1;
 var closedWithEnter = false;
 
 this.SelectParentHelper = {
   populate: function(menulist, items, selectedIndex, zoom) {
     // Clear the current contents of the popup
@@ -150,17 +154,18 @@ this.SelectParentHelper = {
     browser.ownerDocument.defaultView.removeEventListener("keydown", this, true);
     browser.ownerDocument.defaultView.removeEventListener("fullscreen", this, true);
     browser.messageManager.removeMessageListener("Forms:UpdateDropDown", this);
   },
 
 };
 
 function populateChildren(menulist, options, selectedIndex, zoom,
-                          parentElement = null, isGroupDisabled = false, adjustedTextSize = -1) {
+                          parentElement = null, isGroupDisabled = false,
+                          adjustedTextSize = -1, addSearch = true) {
   let element = menulist.menupopup;
 
   // -1 just means we haven't calculated it yet. When we recurse through this function
   // we will pass in adjustedTextSize to save on recalculations.
   if (adjustedTextSize == -1) {
     let win = element.ownerDocument.defaultView;
 
     // Grab the computed text size and multiply it by the remote browser's fullZoom to ensure
@@ -173,29 +178,32 @@ function populateChildren(menulist, opti
   for (let option of options) {
     let isOptGroup = (option.tagName == 'OPTGROUP');
     let item = element.ownerDocument.createElement(isOptGroup ? "menucaption" : "menuitem");
 
     item.setAttribute("label", option.textContent);
     item.style.direction = option.textDirection;
     item.style.fontSize = adjustedTextSize;
     item.hidden = option.display == "none" || (parentElement && parentElement.hidden);
+    // Keep track of which options are hidden by page content, so we can avoid showing
+    // them on search input
+    item.hiddenByContent = item.hidden;
     item.setAttribute("tooltiptext", option.tooltip);
 
     element.appendChild(item);
 
     // A disabled optgroup disables all of its child options.
     let isDisabled = isGroupDisabled || option.disabled;
     if (isDisabled) {
       item.setAttribute("disabled", "true");
     }
 
     if (isOptGroup) {
       populateChildren(menulist, option.children, selectedIndex, zoom,
-                       item, isDisabled, adjustedTextSize);
+                       item, isDisabled, adjustedTextSize, false);
     } else {
       if (option.index == selectedIndex) {
         // We expect the parent element of the popup to be a <xul:menulist> that
         // has the popuponly attribute set to "true". This is necessary in order
         // for a <xul:menupopup> to act like a proper <html:select> dropdown, as
         // the <xul:menulist> does things like remember state and set the
         // _moz-menuactive attribute on the selected <xul:menuitem>.
         menulist.selectedItem = item;
@@ -210,9 +218,129 @@ function populateChildren(menulist, opti
 
       item.setAttribute("value", option.index);
 
       if (parentElement) {
         item.classList.add("contentSelectDropdown-ingroup")
       }
     }
   }
+
+  // Check if search pref is enabled, if this is the first time iterating through
+  // the dropdown, and if the list is long enough for a search element to be added.
+  if (Services.prefs.getBoolPref("dom.forms.selectSearch") && addSearch
+      && element.childElementCount > SEARCH_MINIMUM_ELEMENTS) {
+
+    // Add a search text field as the first element of the dropdown
+    let searchbox = element.ownerDocument.createElement("textbox");
+    searchbox.setAttribute("type", "search");
+    searchbox.addEventListener("input", onSearchInput);
+    searchbox.addEventListener("focus", onSearchFocus);
+    searchbox.addEventListener("blur", onSearchBlur);
+
+    // Handle special keys for exiting search
+    searchbox.addEventListener("keydown", function(event) {
+      if (event.defaultPrevented) {
+        return;
+      }
+      switch (event.key) {
+        case "Escape":
+          searchbox.parentElement.hidePopup();
+          break;
+        case "ArrowDown":
+        case "Enter":
+        case "Tab":
+          searchbox.blur();
+          if (searchbox.nextSibling.localName == "menuitem" &&
+              !searchbox.nextSibling.hidden) {
+            menulist.menuBoxObject.activeChild = searchbox.nextSibling;
+          } else {
+            var currentOption = searchbox.nextSibling;
+            while (currentOption && (currentOption.localName != "menuitem" ||
+                  currentOption.hidden)) {
+              currentOption = currentOption.nextSibling;
+            }
+            if (currentOption) {
+              menulist.menuBoxObject.activeChild = currentOption;
+            } else {
+              searchbox.focus();
+            }
+          }
+          break;
+        default:
+          return;
+      }
+      event.preventDefault();
+    }, true);
+
+    element.insertBefore(searchbox, element.childNodes[0]);
+  }
+
 }
+
+function onSearchInput() {
+  let searchObj = this;
+
+  // Get input from search field, set to all lower case for comparison
+  let input = searchObj.value.toLowerCase();
+  // Get all items in dropdown (could be options or optgroups)
+  let menupopup = searchObj.parentElement;
+  let menuItems = menupopup.querySelectorAll("menuitem, menucaption");
+
+  // Flag used to detect any group headers with no visible options.
+  // These group headers should be hidden.
+  let allHidden = true;
+  // Keep a reference to the previous group header (menucaption) to go back
+  // and set to hidden if all options within are hidden.
+  let prevCaption = null;
+
+  for (let currentItem of menuItems) {
+    // Make sure we don't show any options that were hidden by page content
+    if (!currentItem.hiddenByContent) {
+      // Get label and tooltip (title) from option and change to
+      // lower case for comparison
+      let itemLabel = currentItem.getAttribute("label").toLowerCase();
+      let itemTooltip = currentItem.getAttribute("title").toLowerCase();
+
+      // If search input is empty, all options should be shown
+      if (!input) {
+        currentItem.hidden = false;
+      } else if (currentItem.localName == "menucaption") {
+        if (prevCaption != null) {
+          prevCaption.hidden = allHidden;
+        }
+        prevCaption = currentItem;
+        allHidden = true;
+      } else {
+        if (!currentItem.classList.contains("contentSelectDropdown-ingroup") &&
+            currentItem.previousSibling.classList.contains("contentSelectDropdown-ingroup")) {
+          if (prevCaption != null) {
+            prevCaption.hidden = allHidden;
+          }
+          prevCaption = null;
+          allHidden = true;
+        }
+        if (itemLabel.includes(input) || itemTooltip.includes(input)) {
+          currentItem.hidden = false;
+          allHidden = false;
+        } else {
+          currentItem.hidden = true;
+        }
+      }
+      if (prevCaption != null) {
+        prevCaption.hidden = allHidden;
+      }
+    }
+  }
+}
+
+function onSearchFocus() {
+  let searchObj = this;
+  let menupopup = searchObj.parentElement;
+  menupopup.parentElement.menuBoxObject.activeChild = null;
+  menupopup.setAttribute("ignorekeys", "true");
+}
+
+function onSearchBlur() {
+  let searchObj = this;
+  let menupopup = searchObj.parentElement;
+  menupopup.setAttribute("ignorekeys", "false");
+}