Bug 1311279, scroll the select popup when click+drag is used, r=mconley
authorNeil Deakin <neil@mozilla.com>
Thu, 17 Nov 2016 09:56:43 -0500
changeset 358163 442d408c09b333bdc68c6c98d89e404515f7a826
parent 358162 43b1b5c5d5d00ebf624d8de9c7d5ef030394e899
child 358164 5a03c87a17259a2b78ecfb207246b7b04838545e
push id10621
push userjlund@mozilla.com
push dateMon, 23 Jan 2017 16:02:43 +0000
treeherdermozilla-aurora@dca7b42e6c67 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmconley
bugs1311279
milestone53.0a1
Bug 1311279, scroll the select popup when click+drag is used, r=mconley
browser/base/content/test/general/browser_selectpopup.js
toolkit/content/widgets/popup.xml
toolkit/content/widgets/scrollbox.xml
toolkit/modules/SelectParentHelper.jsm
--- a/browser/base/content/test/general/browser_selectpopup.js
+++ b/browser/base/content/test/general/browser_selectpopup.js
@@ -74,22 +74,28 @@ const PAGECONTENT_SOMEHIDDEN =
 const PAGECONTENT_TRANSLATED =
   "<html><body>" +
   "<div id='div'>" +
   "<iframe id='frame' width='320' height='295' style='border: none;'" +
   "        src='data:text/html,<select id=select autofocus><option>he he he</option><option>boo boo</option><option>baz baz</option></select>'" +
   "</iframe>" +
   "</div></body></html>";
 
-function openSelectPopup(selectPopup, withMouse, selector = "select", win = window) {
+function openSelectPopup(selectPopup, mode = "key", selector = "select", win = window) {
   let popupShownPromise = BrowserTestUtils.waitForEvent(selectPopup, "popupshown");
 
-  if (withMouse) {
-    return Promise.all([popupShownPromise,
-                        BrowserTestUtils.synthesizeMouseAtCenter(selector, { }, win.gBrowser.selectedBrowser)]);
+  if (mode == "click" || mode == "mousedown") {
+    let mousePromise;
+    if (mode == "click") {
+      mousePromise = BrowserTestUtils.synthesizeMouseAtCenter(selector, { }, win.gBrowser.selectedBrowser);
+    } else {
+      mousePromise = BrowserTestUtils.synthesizeMouse(selector, 5, 5, { type: "mousedown" }, win.gBrowser.selectedBrowser);
+    }
+
+    return Promise.all([popupShownPromise, mousePromise]);
   }
 
   EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true, code: "ArrowDown" }, win);
   return popupShownPromise;
 }
 
 function hideSelectPopup(selectPopup, mode = "enter", win = window) {
   let browser = win.gBrowser.selectedBrowser;
@@ -180,26 +186,26 @@ function* doSelectTests(contentType, dtd
 
   yield hideSelectPopup(selectPopup);
 
   is(menulist.selectedIndex, 3, "Item 3 still selected");
   is((yield getInputEvents()), 1, "After closed - number of input events");
   is((yield getChangeEvents()), 1, "After closed - number of change events");
 
   // Opening and closing the popup without changing the value should not fire a change event.
-  yield openSelectPopup(selectPopup, true);
+  yield openSelectPopup(selectPopup, "click");
   yield hideSelectPopup(selectPopup, "escape");
   is((yield getInputEvents()), 1, "Open and close with no change - number of input events");
   is((yield getChangeEvents()), 1, "Open and close with no change - number of change events");
   EventUtils.synthesizeKey("VK_TAB", { });
   EventUtils.synthesizeKey("VK_TAB", { shiftKey: true });
   is((yield getInputEvents()), 1, "Tab away from select with no change - number of input events");
   is((yield getChangeEvents()), 1, "Tab away from select with no change - number of change events");
 
-  yield openSelectPopup(selectPopup, true);
+  yield openSelectPopup(selectPopup, "click");
   EventUtils.synthesizeKey("KEY_ArrowDown", { code: "ArrowDown" });
   yield hideSelectPopup(selectPopup, "escape");
   is((yield getInputEvents()), isWindows ? 2 : 1, "Open and close with change - number of input events");
   is((yield getChangeEvents()), isWindows ? 2 : 1, "Open and close with change - number of change events");
   EventUtils.synthesizeKey("VK_TAB", { });
   EventUtils.synthesizeKey("VK_TAB", { shiftKey: true });
   is((yield getInputEvents()), isWindows ? 2 : 1, "Tab away from select with change - number of input events");
   is((yield getChangeEvents()), isWindows ? 2 : 1, "Tab away from select with change - number of change events");
@@ -231,42 +237,42 @@ add_task(function*() {
 add_task(function*() {
   const pageUrl = "data:text/html," + escape(PAGECONTENT_SMALL);
   let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, pageUrl);
 
   let menulist = document.getElementById("ContentSelectDropdown");
   let selectPopup = menulist.menupopup;
 
   // First, try it when a different <select> element than the one that is open is removed
-  yield openSelectPopup(selectPopup, true, "#one");
+  yield openSelectPopup(selectPopup, "click", "#one");
 
   yield ContentTask.spawn(gBrowser.selectedBrowser, {}, function() {
     content.document.body.removeChild(content.document.getElementById("two"));
   });
 
   // Wait a bit just to make sure the popup won't close.
   yield new Promise(resolve => setTimeout(resolve, 1000));
 
   is(selectPopup.state, "open", "Different popup did not affect open popup");
 
   yield hideSelectPopup(selectPopup);
 
   // Next, try it when the same <select> element than the one that is open is removed
-  yield openSelectPopup(selectPopup, true, "#three");
+  yield openSelectPopup(selectPopup, "click", "#three");
 
   let popupHiddenPromise = BrowserTestUtils.waitForEvent(selectPopup, "popuphidden");
   yield ContentTask.spawn(gBrowser.selectedBrowser, {}, function() {
     content.document.body.removeChild(content.document.getElementById("three"));
   });
   yield popupHiddenPromise;
 
   ok(true, "Popup hidden when select is removed");
 
   // Finally, try it when the tab is closed while the select popup is open.
-  yield openSelectPopup(selectPopup, true, "#one");
+  yield openSelectPopup(selectPopup, "click", "#one");
 
   popupHiddenPromise = BrowserTestUtils.waitForEvent(selectPopup, "popuphidden");
   yield BrowserTestUtils.removeTab(tab);
   yield popupHiddenPromise;
 
   ok(true, "Popup hidden when tab is closed");
 });
 
@@ -274,17 +280,17 @@ add_task(function*() {
 add_task(function*() {
   const pageUrl = "data:text/html," + escape(PAGECONTENT_TRANSLATED);
   let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, pageUrl);
 
   let menulist = document.getElementById("ContentSelectDropdown");
   let selectPopup = menulist.menupopup;
 
   // First, get the position of the select popup when no translations have been applied.
-  yield openSelectPopup(selectPopup, false);
+  yield openSelectPopup(selectPopup);
 
   let rect = selectPopup.getBoundingClientRect();
   let expectedX = rect.left;
   let expectedY = rect.top;
 
   yield hideSelectPopup(selectPopup);
 
   // Iterate through a set of steps which each add more translation to the select's expected position.
@@ -315,17 +321,17 @@ add_task(function*() {
           resolve();
         });
 
         elem.style = contentStep[1];
         elem.getBoundingClientRect();
       });
     });
 
-    yield openSelectPopup(selectPopup, false);
+    yield openSelectPopup(selectPopup);
 
     expectedX += step[2];
     expectedY += step[3];
 
     let popupRect = selectPopup.getBoundingClientRect();
     is(popupRect.left, expectedX, "step " + (stepIndex + 1) + " x");
     is(popupRect.top, expectedY, "step " + (stepIndex + 1) + " y");
 
@@ -386,17 +392,17 @@ add_task(function* test_event_order() {
         type: "click",
         cancelable: true,
         targetIsOption: true,
       },
     ];
 
     for (let mode of ["enter", "click"]) {
       let expected = mode == "enter" ? expectedEnter : expectedClick;
-      yield openSelectPopup(selectPopup, true, mode == "enter" ? "#one" : "#two");
+      yield openSelectPopup(selectPopup, "click", mode == "enter" ? "#one" : "#two");
 
       let eventsPromise = ContentTask.spawn(browser, [mode, expected], function*([contentMode, contentExpected]) {
         return new Promise((resolve) => {
           function onEvent(event) {
             select.removeEventListener(event.type, onEvent);
             Assert.ok(contentExpected.length, "Unexpected event " + event.type);
             let expectation = contentExpected.shift();
             Assert.equal(event.type, expectation.type,
@@ -438,25 +444,57 @@ function* performLargePopupTests(win) {
 
     select.options[60].selected = true;
     select.focus();
   });
 
   let selectPopup = win.document.getElementById("ContentSelectDropdown").menupopup;
   let browserRect = browser.getBoundingClientRect();
 
+  // Check if a drag-select works and scrolls the list.
+  yield openSelectPopup(selectPopup, "mousedown", "select", win);
+
+  let scrollPos = selectPopup.scrollBox.scrollTop;
+  let popupRect = selectPopup.getBoundingClientRect();
+
+  // First, check that scrolling does not occur when the mouse is moved over the
+  // anchor button but not the popup yet.
+  EventUtils.synthesizeMouseAtPoint(popupRect.left + 5, popupRect.top - 10, { type: "mousemove" }, win);
+  is(selectPopup.scrollBox.scrollTop, scrollPos, "scroll position after mousemove over button");
+
+  EventUtils.synthesizeMouseAtPoint(popupRect.left + 20, popupRect.top + 10, { type: "mousemove" }, win);
+
+  // Dragging above the popup scrolls it up.
+  EventUtils.synthesizeMouseAtPoint(popupRect.left + 20, popupRect.top - 20, { type: "mousemove" }, win);
+  ok(selectPopup.scrollBox.scrollTop < scrollPos - 5, "scroll position at drag up");
+
+  // Dragging below the popup scrolls it down.
+  scrollPos = selectPopup.scrollBox.scrollTop;
+  EventUtils.synthesizeMouseAtPoint(popupRect.left + 20, popupRect.bottom + 20, { type: "mousemove" }, win);
+  ok(selectPopup.scrollBox.scrollTop > scrollPos + 5, "scroll position at drag down");
+
+  // Releasing the mouse button and moving the mouse does not change the scroll position.
+  scrollPos = selectPopup.scrollBox.scrollTop;
+  EventUtils.synthesizeMouseAtPoint(popupRect.left + 20, popupRect.bottom + 25, { type: "mouseup" }, win);
+  is(selectPopup.scrollBox.scrollTop, scrollPos, "scroll position at mouseup");
+
+  EventUtils.synthesizeMouseAtPoint(popupRect.left + 20, popupRect.bottom + 20, { type: "mousemove" }, win);
+  is(selectPopup.scrollBox.scrollTop, scrollPos, "scroll position at mouseup again");
+
+  yield hideSelectPopup(selectPopup, "escape", win);
+
   let positions = [
     "margin-top: 300px;",
     "position: fixed; bottom: 100px;",
     "width: 100%; height: 9999px;"
   ];
 
   let position;
-  while (true) {
-    yield openSelectPopup(selectPopup, false, "select", win);
+  while (positions.length) {
+    yield openSelectPopup(selectPopup, "key", "select", 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);
@@ -476,24 +514,21 @@ function* performLargePopupTests(win) {
       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) {
-      break;
-    }
 
     let contentPainted = BrowserTestUtils.contentPainted(browser);
     yield ContentTask.spawn(browser, position, function*(contentPosition) {
       let select = content.document.getElementById("one");
-      select.setAttribute("style", contentPosition);
+      select.setAttribute("style", contentPosition || "");
       select.getBoundingClientRect();
     });
     yield contentPainted;
   }
 }
 
 function* performSelectSearchTests(win) {
   let browser = win.gBrowser.selectedBrowser;
@@ -617,17 +652,17 @@ add_task(function* test_mousemove_correc
   });
 
   yield BrowserTestUtils.synthesizeMouseAtCenter("#one", { type: "mouseup" }, gBrowser.selectedBrowser);
 
   yield hideSelectPopup(selectPopup);
 
   // The popup should be closed when fullscreen mode is entered or exited.
   for (let steps = 0; steps < 2; steps++) {
-    yield openSelectPopup(selectPopup, true);
+    yield openSelectPopup(selectPopup, "click");
     let popupHiddenPromise = BrowserTestUtils.waitForEvent(selectPopup, "popuphidden");
     let sizeModeChanged = BrowserTestUtils.waitForEvent(window, "sizemodechange");
     BrowserFullScreen();
     yield sizeModeChanged;
     yield popupHiddenPromise;
   }
 
   yield BrowserTestUtils.removeTab(tab);
--- a/toolkit/content/widgets/popup.xml
+++ b/toolkit/content/widgets/popup.xml
@@ -241,16 +241,22 @@
 
     <content>
       <xul:arrowscrollbox class="popup-internal-box" flex="1" orient="vertical"
                           smoothscroll="false">
         <children/>
       </xul:arrowscrollbox>
     </content>
 
+    <implementation>
+      <field name="scrollBox" readonly="true">
+        document.getAnonymousElementByAttribute(this, "class", "popup-internal-box");
+      </field>
+    </implementation>
+
     <handlers>
       <handler event="popupshowing" phase="target">
         <![CDATA[
           var array = [];
           var width = 0;
           for (var menuitem = this.firstChild; menuitem; menuitem = menuitem.nextSibling) {
             if (menuitem.localName == "menuitem" && menuitem.hasAttribute("acceltext")) {
               var accel = document.getAnonymousElementByAttribute(menuitem, "anonid", "accel");
@@ -628,15 +634,15 @@
         this._isMouseOver = false;
         this._mouseOutCount = 0;
       ]]></handler>
     </handlers>
   </binding>
 
   <binding id="popup-scrollbars" extends="chrome://global/content/bindings/popup.xml#popup">
     <content>
-      <xul:hbox class="popup-internal-box" flex="1" orient="vertical" style="overflow: auto;">
+      <xul:scrollbox class="popup-internal-box" flex="1" orient="vertical" style="overflow: auto;">
         <children/>
-      </xul:hbox>
+      </xul:scrollbox>
     </content>
   </binding>
 
 </bindings>
--- a/toolkit/content/widgets/scrollbox.xml
+++ b/toolkit/content/widgets/scrollbox.xml
@@ -16,16 +16,25 @@
   </binding>
 
   <binding id="scrollbox" extends="chrome://global/content/bindings/scrollbox.xml#scrollbox-base">
     <content>
       <xul:box class="box-inherit scrollbox-innerbox" xbl:inherits="orient,align,pack,dir" flex="1">
         <children/>
       </xul:box>
     </content>
+
+    <implementation>
+      <method name="scrollByIndex">
+        <parameter name="index"/>
+        <body>
+          this.boxObject.scrollByIndex(index);
+        </body>
+      </method>
+    </implementation>
   </binding>
 
   <binding id="arrowscrollbox" extends="chrome://global/content/bindings/scrollbox.xml#scrollbox-base">
     <content>
       <xul:autorepeatbutton class="autorepeatbutton-up"
                             anonid="scrollbutton-up"
                             xbl:inherits="orient,collapsed=notoverflowing,disabled=scrolledtostart"
                             oncommand="_autorepeatbuttonScroll(event);"/>
--- a/toolkit/modules/SelectParentHelper.jsm
+++ b/toolkit/modules/SelectParentHelper.jsm
@@ -10,25 +10,31 @@ this.EXPORTED_SYMBOLS = [
 
 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;
 
+// Interval between autoscrolls
+const AUTOSCROLL_INTERVAL = 25;
+
 // 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 = {
+  draggedOverPopup: false,
+  scrollTimer: 0,
+
   populate(menulist, items, selectedIndex, zoom) {
     // Clear the current contents of the popup
     menulist.menupopup.textContent = "";
     currentZoom = zoom;
     currentMenulist = menulist;
     populateChildren(menulist, items, selectedIndex, zoom);
   },
 
@@ -60,38 +66,85 @@ this.SelectParentHelper = {
     menupopup.classList.toggle("isOpenedViaTouch", isOpenedViaTouch);
 
     let constraintRect = browser.getBoundingClientRect();
     constraintRect = new win.DOMRect(constraintRect.left + win.mozInnerScreenX,
                                      constraintRect.top + win.mozInnerScreenY,
                                      constraintRect.width, constraintRect.height);
     menupopup.setConstraintRect(constraintRect);
     menupopup.openPopupAtScreenRect(AppConstants.platform == "macosx" ? "selection" : "after_start", rect.left, rect.top, rect.width, rect.height, false, false);
+
+    // Set up for dragging
+    menupopup.setCaptureAlways();
+    this.draggedOverPopup = false;
+    menupopup.addEventListener("mousemove", this);
   },
 
   hide(menulist, browser) {
     if (currentBrowser == browser) {
       menulist.menupopup.hidePopup();
     }
   },
 
+  clearScrollTimer() {
+    if (this.scrollTimer) {
+      let win = currentBrowser.ownerDocument.defaultView;
+      win.clearInterval(this.scrollTimer);
+      this.scrollTimer = 0;
+    }
+  },
+
   handleEvent(event) {
     switch (event.type) {
       case "mouseup":
+        this.clearScrollTimer();
+        currentMenulist.menupopup.removeEventListener("mousemove", this);
         currentBrowser.messageManager.sendAsyncMessage("Forms:MouseUp", {});
         break;
 
       case "mouseover":
         currentBrowser.messageManager.sendAsyncMessage("Forms:MouseOver", {});
         break;
 
       case "mouseout":
         currentBrowser.messageManager.sendAsyncMessage("Forms:MouseOut", {});
         break;
 
+      case "mousemove":
+        let menupopup = currentMenulist.menupopup;
+        let popupRect = menupopup.getOuterScreenRect();
+
+        this.clearScrollTimer();
+
+        // If dragging outside the top or bottom edge of the popup, but within
+        // the popup area horizontally, scroll the list in that direction. The
+        // draggedOverPopup flag is used to ensure that scrolling does not start
+        // until the mouse has moved over the popup first, preventing scrolling
+        // while over the dropdown button.
+        if (event.screenX >= popupRect.left && event.screenX <= popupRect.right) {
+          if (!this.draggedOverPopup) {
+            if (event.screenY > popupRect.top && event.screenY < popupRect.bottom) {
+              this.draggedOverPopup = true;
+            }
+          }
+
+          if (this.draggedOverPopup &&
+              (event.screenY <= popupRect.top || event.screenY >= popupRect.bottom)) {
+            let scrollAmount = event.screenY <= popupRect.top ? -1 : 1;
+            menupopup.scrollBox.scrollByIndex(scrollAmount);
+
+            let win = currentBrowser.ownerDocument.defaultView;
+            this.scrollTimer = win.setInterval(function() {
+              menupopup.scrollBox.scrollByIndex(scrollAmount);
+            }, AUTOSCROLL_INTERVAL);
+          }
+        }
+
+        break;
+
       case "keydown":
         if (event.keyCode == event.DOM_VK_RETURN) {
           closedWithEnter = true;
         }
         break;
 
       case "command":
         if (event.target.hasAttribute("value")) {
@@ -107,16 +160,18 @@ this.SelectParentHelper = {
           currentMenulist.menupopup.hidePopup();
         }
         break;
 
       case "popuphidden":
         currentBrowser.messageManager.sendAsyncMessage("Forms:DismissedDropDown", {});
         let popup = event.target;
         this._unregisterListeners(currentBrowser, popup);
+        this.clearScrollTimer();
+        popup.releaseCapture();
         popup.parentNode.hidden = true;
         currentBrowser = null;
         currentMenulist = null;
         currentZoom = 1;
         break;
     }
   },