Bug 1164028 - Show login details and allow manual fill from a sliding subview in the login fill doorhanger. r=Gijs
authorPaolo Amadini <paolo.mozmail@amadzone.org>
Mon, 08 Jun 2015 16:37:25 +0200
changeset 248064 1391f87657d9abefd9e75971801c8ee2e96e8a96
parent 248063 5097e037c4b0de532f2e8571e2a05d8335108f5f
child 248065 571e6f0262a24874b4156119805d927a2ad7f8b3
push id60888
push userkwierso@gmail.com
push dateThu, 11 Jun 2015 01:38:38 +0000
treeherdermozilla-inbound@39e638ed06bf [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersGijs
bugs1164028
milestone41.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 1164028 - Show login details and allow manual fill from a sliding subview in the login fill doorhanger. r=Gijs
browser/base/content/browser.css
browser/base/content/popup-notifications.inc
browser/themes/shared/login-doorhanger.inc.css
toolkit/components/passwordmgr/LoginDoorhangers.jsm
toolkit/components/passwordmgr/LoginManagerParent.jsm
toolkit/components/passwordmgr/test/browser/browser_filldoorhanger.js
toolkit/themes/linux/global/popup.css
toolkit/themes/osx/global/popup.css
toolkit/themes/windows/global/popup.css
--- a/browser/base/content/browser.css
+++ b/browser/base/content/browser.css
@@ -1286,8 +1286,12 @@ toolbarpaletteitem[place="palette"][hidd
 .popup-notification-footer[popupid="bad-content"] {
   display: none;
 }
 
 .popup-notification-footer[popupid="bad-content"][mixedblockdisabled],
 .popup-notification-footer[popupid="bad-content"][trackingblockdisabled] {
   display: block;
 }
+
+#login-fill-doorhanger:not([inDetailView]) > #login-fill-clickcapturer {
+  pointer-events: none;
+}
--- a/browser/base/content/popup-notifications.inc
+++ b/browser/base/content/popup-notifications.inc
@@ -57,22 +57,32 @@
     <popupnotification id="password-notification" hidden="true">
       <popupnotificationcontent orient="vertical">
         <textbox id="password-notification-username"/>
         <textbox id="password-notification-password" type="password"
                  disabled="true"/>
       </popupnotificationcontent>
     </popupnotification>
 
-    <vbox id="login-fill-doorhanger" hidden="true">
-      <description id="login-fill-testing"
-                   value="Thanks for testing the login fill doorhanger!"/>
-      <textbox id="login-fill-filter"/>
-      <richlistbox id="login-fill-list"/>
-    </vbox>
+    <stack id="login-fill-doorhanger" hidden="true">
+      <vbox id="login-fill-mainview">
+        <description id="login-fill-testing"
+                     value="Thanks for testing the login fill doorhanger!"/>
+        <textbox id="login-fill-filter"/>
+        <richlistbox id="login-fill-list"/>
+      </vbox>
+      <vbox id="login-fill-clickcapturer"/>
+      <vbox id="login-fill-details">
+        <textbox id="login-fill-username" readonly="true"/>
+        <textbox id="login-fill-password" type="password" disabled="true"/>
+        <hbox>
+          <button id="login-fill-use" label="Use in form"/>
+        </hbox>
+      </vbox>
+    </stack>
 
 #ifdef E10S_TESTING_ONLY
     <popupnotification id="enable-e10s-notification" hidden="true">
       <popupnotificationcontent orient="vertical"/>
     </popupnotification>
 #endif
 
     <popupnotification id="addon-progress-notification" hidden="true">
--- a/browser/themes/shared/login-doorhanger.inc.css
+++ b/browser/themes/shared/login-doorhanger.inc.css
@@ -1,8 +1,43 @@
+#notification-popup[popupid="login-fill"] > .panel-arrowcontainer > .panel-arrowcontent {
+  /* Since we display a sliding subview that extends to the border, we cannot
+   * keep the default padding of arrow panels. We use the same padding in the
+   * individual content views instead. Since we removed the padding, we also
+   * have to ensure the contents are clipped to the border box. */
+  padding: 0;
+  overflow: hidden;
+}
+
+#login-fill-mainview,
+#login-fill-details {
+  padding: var(--panel-arrowcontent-padding);
+}
+
+#login-fill-doorhanger[inDetailView] > #login-fill-mainview {
+  transform: translateX(-14px);
+}
+
+#login-fill-mainview,
+#login-fill-details {
+  transition: transform 150ms;
+}
+
+#login-fill-doorhanger:not([inDetailView]) > #login-fill-details {
+  transform: translateX(105%);
+}
+
+#login-fill-doorhanger:not([inDetailView]) > #login-fill-details:-moz-locale-dir(rtl) {
+  transform: translateX(-105%);
+}
+
+#login-fill-doorhanger[inDetailView] > #login-fill-clickcapturer {
+  background-color: hsla(210,4%,10%,.1);
+}
+
 #login-fill-testing {
   color: #b33;
   font-weight: bold;
 }
 
 #login-fill-list {
   border: 1px solid black;
   max-height: 20em;
@@ -26,8 +61,19 @@
   color: #888;
   font-style: italic;
 }
 
 .login-username {
   margin: 4px;
   color: #888;
 }
+
+#login-fill-details {
+  padding: 4px;
+  background: var(--panel-arrowcontent-background);
+  color: var(--panel-arrowcontent-color);
+  background-clip: padding-box;
+  border-left: 1px solid hsla(210,4%,10%,.3);
+  box-shadow: 0 3px 5px hsla(210,4%,10%,.1),
+              0 0 7px hsla(210,4%,10%,.1);
+  -moz-margin-start: 38px;
+}
--- a/toolkit/components/passwordmgr/LoginDoorhangers.jsm
+++ b/toolkit/components/passwordmgr/LoginDoorhangers.jsm
@@ -10,29 +10,49 @@ this.EXPORTED_SYMBOLS = [
 
 const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/LoginManagerParent.jsm");
 
 const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
 
+// Helper function needed because the "disabled" property may not be available
+// if the XBL binding of the UI control has not been constructed yet.
+function setDisabled(element, disabled) {
+  if (disabled) {
+    element.setAttribute("disabled", "true");
+  } else {
+    element.removeAttribute("disabled");
+  }
+}
+
 this.LoginDoorhangers = {};
 
 /**
  * Doorhanger for selecting and filling logins.
  *
  * @param {Object} properties
  *        Properties from this object will be applied to the new instance.
  */
 this.LoginDoorhangers.FillDoorhanger = function (properties) {
-  this.onFilterInput = this.onFilterInput.bind(this);
-  this.onListDblClick = this.onListDblClick.bind(this);
-  this.onListKeyPress = this.onListKeyPress.bind(this);
-
+  // Set up infrastructure to access our elements and listen to events.
+  this.el = new Proxy({}, {
+    get: (target, name) => {
+      return this.chromeDocument.getElementById("login-fill-" + name);
+    },
+  });
+  this.eventHandlers = [];
+  for (let elementName of Object.keys(this.events)) {
+    let handlers = this.events[elementName];
+    for (let eventName of Object.keys(handlers)) {
+      let handler = handlers[eventName];
+      this.eventHandlers.push([elementName, eventName, handler.bind(this)]);
+    }
+  };
   for (let name of Object.getOwnPropertyNames(properties)) {
     this[name] = properties[name];
   }
 };
 
 this.LoginDoorhangers.FillDoorhanger.prototype = {
   /**
    * Whether the elements for this doorhanger are currently in the document.
@@ -47,17 +67,17 @@ this.LoginDoorhangers.FillDoorhanger.pro
    * web page is moved to a different chrome window by the swapDocShells method.
    */
   set browser(browser) {
     const MAX_DATE_VALUE = new Date(8640000000000000);
 
     this._browser = browser;
 
     let doorhanger = this;
-    let PopupNotifications = this.chomeDocument.defaultView.PopupNotifications;
+    let PopupNotifications = this.chromeDocument.defaultView.PopupNotifications;
     let notification = PopupNotifications.show(
       browser,
       "login-fill",
       "",
       "login-fill-notification-icon",
       null,
       null,
       {
@@ -66,18 +86,17 @@ this.LoginDoorhangers.FillDoorhanger.pro
         // visible. We'll remove the notification manually when the page
         // changes, after we had time to check its final state asynchronously.
         timeout: MAX_DATE_VALUE,
         eventCallback: function (topic, otherBrowser) {
           switch (topic) {
             case "shown":
               // Since we specified the "dismissed" option, this event will only
               // be called after the "show" method returns, so the reference to
-              // "this.notification" will be available at this point.
-              doorhanger.bound = true;
+              // "this.notification" will be available in "bind" at this point.
               doorhanger.promiseHidden =
                          new Promise(resolve => doorhanger.onUnbind = resolve);
               doorhanger.bind();
               break;
 
             case "dismissed":
             case "removed":
               if (doorhanger.bound) {
@@ -104,25 +123,25 @@ this.LoginDoorhangers.FillDoorhanger.pro
   _browser: null,
 
   /**
    * DOM document to which the doorhanger is currently associated.
    *
    * This may change during the lifetime of the doorhanger, in case the web page
    * is moved to a different chrome window by the swapDocShells method.
    */
-  get chomeDocument() {
+  get chromeDocument() {
     return this.browser.ownerDocument;
   },
 
   /**
    * Hides this notification, if the notification panel is currently open.
    */
   hide() {
-    let PopupNotifications = this.chomeDocument.defaultView.PopupNotifications;
+    let PopupNotifications = this.chromeDocument.defaultView.PopupNotifications;
     if (PopupNotifications.isPanelOpen) {
       PopupNotifications.panel.hidePopup();
     }
   },
 
   /**
    * Promise resolved as soon as the notification is hidden.
    */
@@ -134,46 +153,57 @@ this.LoginDoorhangers.FillDoorhanger.pro
   remove() {
     this.notification.remove();
   },
 
   /**
    * Binds this doorhanger to its UI controls.
    */
   bind() {
-    this.element = this.chomeDocument.getElementById("login-fill-doorhanger");
-    this.list = this.chomeDocument.getElementById("login-fill-list");
-    this.filter = this.chomeDocument.getElementById("login-fill-filter");
-
-    this.filter.setAttribute("value", this.filterString);
+    // Since this may ask for the master password, we must do it at bind time.
+    if (this.autoDetailLogin) {
+      let formLogins = Services.logins.findLogins({}, this.loginFormOrigin, "",
+                                                  null);
+      if (formLogins.length == 1) {
+        this.detailLogin = formLogins[0];
+      }
+      this.autoDetailLogin = false;
+    }
 
+    this.el.filter.setAttribute("value", this.filterString);
     this.refreshList();
+    this.refreshDetailView();
 
-    this.filter.addEventListener("input", this.onFilterInput);
-    this.list.addEventListener("dblclick", this.onListDblClick);
-    this.list.addEventListener("keypress", this.onListKeyPress);
+    this.eventHandlers.forEach(([elementName, eventName, handler]) => {
+      this.el[elementName].addEventListener(eventName, handler, true);
+    });
 
     // Move the main element to the notification panel for displaying.
-    this.notification.owner.panel.firstElementChild.appendChild(this.element);
-    this.element.hidden = false;
+    this.notification.owner.panel.firstElementChild.appendChild(this.el.doorhanger);
+    this.el.doorhanger.hidden = false;
+
+    this.bound = true;
   },
 
   /**
    * Unbinds this doorhanger from its UI controls.
    */
   unbind() {
-    this.filter.removeEventListener("input", this.onFilterInput);
-    this.list.removeEventListener("dblclick", this.onListDblClick);
-    this.list.removeEventListener("keypress", this.onListKeyPress);
+    this.bound = false;
+
+    this.eventHandlers.forEach(([elementName, eventName, handler]) => {
+      this.el[elementName].removeEventListener(eventName, handler, true);
+    });
 
     this.clearList();
 
     // Place the element back in the document for the next time we need it.
-    this.element.hidden = true;
-    this.chomeDocument.getElementById("mainPopupSet").appendChild(this.element);
+    this.el.doorhanger.hidden = true;
+    this.chromeDocument.getElementById("mainPopupSet")
+                       .appendChild(this.el.doorhanger);
   },
 
   /**
    * Origin for which the manual fill UI should be displayed, for example
    * "http://www.example.com".
    */
   loginFormOrigin: "",
 
@@ -184,21 +214,88 @@ this.LoginDoorhangers.FillDoorhanger.pro
   loginFormPresent: false,
 
   /**
    * User-editable string used to filter the list of all logins.
    */
   filterString: "",
 
   /**
-   * Handles text changes in the filter textbox.
+   * Show login details automatically when the panel is first opened.
+   */
+  autoDetailLogin: false,
+
+  /**
+   * Indicates which particular login to show in the detail view.
+   */
+  set detailLogin(detailLogin) {
+    this._detailLogin = detailLogin;
+    if (this.bound) {
+      this.refreshDetailView();
+    }
+  },
+  get detailLogin() {
+    return this._detailLogin;
+  },
+  _detailLogin: null,
+
+  /**
+   * Prototype functions for event handling.
    */
-  onFilterInput() {
-    this.filterString = this.filter.value;
-    this.refreshList();
+  events: {
+    mainview: {
+      focus(event) {
+        // If keyboard focus returns to any control in the the main view (for
+        // example using SHIFT+TAB) close the details view.
+        this.detailLogin = null;
+      },
+    },
+    filter: {
+      input(event) {
+        this.filterString = this.el.filter.value;
+        this.refreshList();
+      },
+    },
+    list: {
+      click(event) {
+        if (event.button == 0 && this.el.list.selectedItem) {
+          this.displaySelectedLoginDetails();
+        }
+      },
+      keypress(event) {
+        if (event.keyCode == Ci.nsIDOMKeyEvent.DOM_VK_RETURN &&
+            this.el.list.selectedItem) {
+          this.displaySelectedLoginDetails();
+        }
+      },
+    },
+    clickcapturer: {
+      click(event) {
+        this.detailLogin = null;
+      },
+    },
+    details: {
+      transitionend(event) {
+        // We must set focus to the detail controls only when the transition has
+        // ended, otherwise focus will interfere with the animation. We do this
+        // only when we're showing the detail view, not when leaving.
+        if (event.target == this.el.details && this.detailLogin) {
+          if (this.loginFormPresent) {
+            this.el.use.focus();
+          } else {
+            this.el.username.focus();
+          }
+        }
+      },
+    },
+    use: {
+      command(event) {
+        this.fillLogin();
+      },
+    },
   },
 
   /**
    * Rebuilds the list of logins.
    */
   refreshList() {
     this.clearList();
 
@@ -207,73 +304,76 @@ this.LoginDoorhangers.FillDoorhanger.pro
     if (filterToUse) {
       formLogins = formLogins.filter(login => {
         return login.hostname.toLowerCase().indexOf(filterToUse) != -1 ||
                login.username.toLowerCase().indexOf(filterToUse) != -1 ;
       });
     }
 
     for (let { hostname, username } of formLogins) {
-      let item = this.chomeDocument.createElementNS(XUL_NS, "richlistitem");
+      let item = this.chromeDocument.createElementNS(XUL_NS, "richlistitem");
       item.classList.add("login-fill-item");
       item.setAttribute("hostname", hostname);
       item.setAttribute("username", username);
       if (hostname != this.loginFormOrigin) {
         item.classList.add("different-hostname");
       }
-      if (!this.loginFormPresent) {
-        item.setAttribute("disabled", "true");
-      }
-      this.list.appendChild(item);
+      this.el.list.appendChild(item);
     }
   },
 
   /**
    * Clears the list of logins.
    */
   clearList() {
-    while (this.list.firstChild) {
-      this.list.removeChild(this.list.firstChild);
+    let list = this.el.list;
+    while (list.firstChild) {
+      list.firstChild.remove();
     }
   },
 
   /**
-   * Handles the action associated to a login item.
+   * Updates all the controls of the detail view based on the chosen login.
    */
-  onListDblClick(event) {
-    if (event.button != 0 || !this.list.selectedItem) {
-      return;
+  refreshDetailView() {
+    if (this.detailLogin) {
+      this.el.username.setAttribute("value", this.detailLogin.username);
+      this.el.password.setAttribute("value", this.detailLogin.password);
+      this.el.doorhanger.setAttribute("inDetailView", "true");
+      setDisabled(this.el.username, false);
+      setDisabled(this.el.use, !this.loginFormPresent);
+    } else {
+      this.el.doorhanger.removeAttribute("inDetailView");
+      // We must disable all the detail controls to ensure they cannot be
+      // selected with the keyboard while they are outside the visible area.
+      setDisabled(this.el.username, true);
+      setDisabled(this.el.use, true);
     }
-    this.fillLogin();
   },
-  onListKeyPress(event) {
-    if (event.keyCode != Ci.nsIDOMKeyEvent.DOM_VK_RETURN ||
-        !this.list.selectedItem) {
+
+  displaySelectedLoginDetails() {
+    let selectedItem = this.el.list.selectedItem;
+    let hostLogins = Services.logins.findLogins({},
+                               selectedItem.getAttribute("hostname"), "", null);
+    let login = hostLogins.find(login => {
+      return login.username == selectedItem.getAttribute("username");
+    });
+    if (!login) {
+      Cu.reportError("The selected login has been removed in the meantime.");
       return;
     }
-    this.fillLogin();
+    this.detailLogin = login;
   },
+
   fillLogin() {
-    if (this.list.selectedItem.hasAttribute("disabled")) {
-      return;
-    }
-    let formLogins = Services.logins.findLogins({}, "", "", null);
-    let login = formLogins.find(login => {
-      return login.hostname == this.list.selectedItem.getAttribute("hostname") &&
-             login.username == this.list.selectedItem.getAttribute("username");
-    });
-    if (login) {
-      LoginManagerParent.fillForm({
-        browser: this.browser,
-        loginFormOrigin: this.loginFormOrigin,
-        login,
-      }).catch(Cu.reportError);
-    } else {
-      Cu.reportError("The selected login has been removed in the meantime.");
-    }
+    LoginManagerParent.fillForm({
+      browser: this.browser,
+      loginFormOrigin: this.loginFormOrigin,
+      login: this.detailLogin,
+    }).catch(Cu.reportError);
     this.hide();
   },
 };
 
 /**
  * Retrieves an existing FillDoorhanger associated with a browser, or null if an
  * associated doorhanger of that type cannot be found.
  *
--- a/toolkit/components/passwordmgr/LoginManagerParent.jsm
+++ b/toolkit/components/passwordmgr/LoginManagerParent.jsm
@@ -602,21 +602,24 @@ var LoginManagerParent = {
       if (!showLoginAnchor) {
         fillDoorhanger.remove();
         return;
       }
       // We should only update the state of the doorhanger while it is hidden.
       yield fillDoorhanger.promiseHidden;
       fillDoorhanger.loginFormPresent = loginFormPresent;
       fillDoorhanger.loginFormOrigin = loginFormOrigin;
-      fillDoorhanger.filterString = loginFormOrigin;
+      fillDoorhanger.filterString = hasLogins ? loginFormOrigin : "";
+      fillDoorhanger.detailLogin = null;
+      fillDoorhanger.autoDetailLogin = true;
       return;
     }
     if (showLoginAnchor) {
       fillDoorhanger = new LoginDoorhangers.FillDoorhanger({
         browser,
         loginFormPresent,
         loginFormOrigin,
-        filterString: loginFormOrigin,
+        filterString: hasLogins ? loginFormOrigin : "",
+        autoDetailLogin: true,
       });
     }
   }),
 };
--- a/toolkit/components/passwordmgr/test/browser/browser_filldoorhanger.js
+++ b/toolkit/components/passwordmgr/test/browser/browser_filldoorhanger.js
@@ -46,20 +46,30 @@ add_task(function* test_fill() {
                                                      "Shown");
     anchor.click();
     yield promiseShown;
 
     let list = document.getElementById("login-fill-list");
     Assert.equal(list.childNodes.length, 1,
                  "list.childNodes.length === 1");
 
+    // The button will be focused after the "transitionend" event.
+    list.focus();
+    yield new Promise(resolve => executeSoon(resolve));
+    let details = document.getElementById("login-fill-details");
+    let promiseSubview = BrowserTestUtils.waitForEvent(details,
+                                                       "transitionend", true,
+                                                       e => e.target == details);
+    EventUtils.sendMouseEvent({ type: "click" }, list.childNodes[0]);
+    yield promiseSubview;
+
+    // Clicking the button will dismiss the panel.
     let promiseHidden = BrowserTestUtils.waitForEvent(PopupNotifications.panel,
                                                       "popuphidden");
-    list.focus();
-    EventUtils.sendMouseEvent({ type: "dblclick" }, list.childNodes[0]);
+    document.getElementById("login-fill-use").click();
     yield promiseHidden;
 
     let result = yield ContentTask.spawn(browser, null, function* () {
       let doc = content.document;
       return {
         username: doc.getElementById("form-basic-username").value,
         password: doc.getElementById("form-basic-password").value,
       }
--- a/toolkit/themes/linux/global/popup.css
+++ b/toolkit/themes/linux/global/popup.css
@@ -1,14 +1,22 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 @namespace url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul");
 
+/* ::::: Variables ::::: */
+.panel-arrowcontent {
+  --panel-arrowcontent-padding: 10px;
+  --panel-arrowcontent-background: -moz-Dialog;
+  --panel-arrowcontent-color: -moz-DialogText;
+  --panel-arrowcontent-border: 1px solid ThreeDShadow;
+}
+
 /* ::::: menupopup ::::: */
 
 menupopup,
 panel {
   -moz-appearance: menupopup;
   min-width: 1px;
   color: MenuText;
 }
@@ -27,20 +35,20 @@ panel[type="arrow"][side="bottom"] {
 
 panel[type="arrow"][side="left"],
 panel[type="arrow"][side="right"] {
   margin-top: -16px;
   margin-bottom: -16px;
 }
 
 .panel-arrowcontent {
-  padding: 10px;
-  color: -moz-DialogText;
-  background: -moz-Dialog;
-  border: 1px solid ThreeDShadow;
+  padding: var(--panel-arrowcontent-padding);
+  color: var(--panel-arrowcontent-color);
+  background: var(--panel-arrowcontent-background);
+  border: var(--panel-arrowcontent-border);
 }
 
 .panel-arrow[side="top"],
 .panel-arrow[side="bottom"] {
   list-style-image: url("chrome://global/skin/icons/panelarrow-vertical.svg");
   position: relative;
   margin-left: 6px;
   margin-right: 6px;
--- a/toolkit/themes/osx/global/popup.css
+++ b/toolkit/themes/osx/global/popup.css
@@ -1,15 +1,16 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 @namespace url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul");
 
 .panel-arrowcontent {
+  --panel-arrowcontent-padding: 16px;
   --panel-arrowcontent-background: linear-gradient(hsla(0,0%,99%,1), hsla(0,0%,99%,.975) 10%, hsla(0,0%,98%,.975));
   --panel-arrowcontent-color: hsl(0,0%,10%);
   --panel-arrowcontent-border: none;
 }
 
 menupopup,
 panel {
   -moz-appearance: menupopup;
@@ -47,17 +48,17 @@ panel[type="arrow"][side="right"] {
 
 .panel-arrowcontent {
   -moz-appearance: none;
   background: var(--panel-arrowcontent-background);
   border-radius: 3.5px;
   box-shadow: 0 0 0 1px hsla(210,4%,10%,.05);
   color: var(--panel-arrowcontent-color);
   border: var(--panel-arrowcontent-border);
-  padding: 16px;
+  padding: var(--panel-arrowcontent-padding);
   margin: 1px;
 }
 
 .panel-arrow[side="top"] {
   list-style-image: url("chrome://global/skin/arrow/panelarrow-vertical.png");
   margin-left: 16px;
   margin-right: 16px;
   margin-bottom: -1px;
--- a/toolkit/themes/windows/global/popup.css
+++ b/toolkit/themes/windows/global/popup.css
@@ -1,16 +1,17 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 @namespace url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul");
 
 /* ::::: Variables ::::: */
 .panel-arrowcontent {
+  --panel-arrowcontent-padding: 10px;
   --panel-arrowcontent-background: -moz-field;
   --panel-arrowcontent-color: -moz-FieldText;
   --panel-arrowcontent-border: 1px solid ThreeDShadow;
 }
 
 /* ::::: menupopup ::::: */
 
 menupopup,
@@ -50,17 +51,17 @@ panel[type="arrow"][side="bottom"] {
 
 panel[type="arrow"][side="left"],
 panel[type="arrow"][side="right"] {
   margin-top: -20px;
   margin-bottom: -20px;
 }
 
 .panel-arrowcontent {
-  padding: 10px;
+  padding: var(--panel-arrowcontent-padding);
   color: var(--panel-arrowcontent-color);
   background: var(--panel-arrowcontent-background);
   background-clip: padding-box;
   border: var(--panel-arrowcontent-border);
   margin: 4px;
 }
 
 %ifdef XP_WIN