Bug 1309935 - Add ability to find within select dropdown when over 40 elements. r?jaws draft
authorTyler Maklebust <tmaklebust@gmail.com>
Tue, 17 Jan 2017 15:40:15 -0500
changeset 462629 2cfeb30e08f7b1e518b122b45bd8d2c519f7a4ec
parent 462512 6a23526fe5168087d7e4132c0705aefcaed5f571
child 542464 27b3d1acea391954fb80c3d125b69f6d1b6be160
push id41830
push usermconley@mozilla.com
push dateTue, 17 Jan 2017 21:10:54 +0000
reviewersjaws
bugs1309935
milestone53.0a1
Bug 1309935 - Add ability to find within select dropdown when over 40 elements. r?jaws Original patch by Tyler Maklebust <tmaklebust@gmail.com> and Jared Beach <beachjar@msu.edu> MozReview-Commit-ID: 7FW6PHIrXF4
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>" +
@@ -441,23 +456,29 @@ 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;
 
+      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;
+      }
       // Some of the styles applied to the menuitems are percentages, meaning
       // that the final layout calculations returned by getBoundingClientRect()
       // might return floating point values. We don't care about sub-pixel
       // accuracy, and only care about the final pixel value, so we add a
       // fuzz-factor of 1.
-      SimpleTest.isfuzzy(selectPopup.childNodes[60].getBoundingClientRect().bottom,
+      SimpleTest.isfuzzy(selectPopup.childNodes[selectedOption].getBoundingClientRect().bottom,
                          selectPopup.getBoundingClientRect().bottom - bpBottom,
                          1, "Popup scroll at correct position " + bpBottom);
     }
 
     yield hideSelectPopup(selectPopup, "enter", win);
 
     position = positions.shift();
     if (!position) {
@@ -469,16 +490,88 @@ function* performLargePopupTests(win) {
       let select = content.document.getElementById("one");
       select.setAttribute("style", contentPosition);
       select.getBoundingClientRect();
     });
     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
@@ -1184,16 +1184,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(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");
+}