Bug 1335483 - Compare the user agent value for the select option styling to determine if content has opted-in to custom styling. r?mconley draft
authorJared Wein <jwein@mozilla.com>
Tue, 31 Jan 2017 15:58:27 -0500
changeset 470528 2c3e5a0df84df676a4404f9b0d7f781b1c4e4150
parent 470527 b18cb37f3ca5eb13a2ab7e716792d55f45a236e6
child 478939 6a84f94a9644c220be986099d692cc2d53d33d3c
push id44060
push userbmo:jaws@mozilla.com
push dateFri, 03 Feb 2017 21:54:35 +0000
reviewersmconley
bugs1335483
milestone54.0a1
Bug 1335483 - Compare the user agent value for the select option styling to determine if content has opted-in to custom styling. r?mconley MozReview-Commit-ID: 8wqclzJr2si
browser/base/content/test/general/browser_selectpopup.js
toolkit/content/widgets/browser.xml
toolkit/content/widgets/remote-browser.xml
toolkit/modules/SelectContentHelper.jsm
toolkit/modules/SelectParentHelper.jsm
toolkit/themes/linux/global/menu.css
--- a/browser/base/content/test/general/browser_selectpopup.js
+++ b/browser/base/content/test/general/browser_selectpopup.js
@@ -76,21 +76,29 @@ 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>";
 
 const PAGECONTENT_COLORS =
-  "<html><head><style>.blue { color: #fff; background-color: #00f; } .green { color: #800080; background-color: green; }</style>" +
+  "<html><head><style>" +
+  "  .blue { color: #fff; background-color: #00f; }" +
+  "  .green { color: #800080; background-color: green; }" +
+  "  .defaultColor { color: -moz-ComboboxText; }" +
+  "  .defaultBackground { background-color: -moz-Combobox; }" +
+  "</style>" +
   "<body><select id='one'>" +
   '  <option value="One" style="color: #fff; background-color: #f00;">{"color": "rgb(255, 255, 255)", "backgroundColor": "rgb(255, 0, 0)"}</option>' +
   '  <option value="Two" class="blue">{"color": "rgb(255, 255, 255)", "backgroundColor": "rgb(0, 0, 255)"}</option>' +
   '  <option value="Three" class="green">{"color": "rgb(128, 0, 128)", "backgroundColor": "rgb(0, 128, 0)"}</option>' +
+  '  <option value="Four" class="defaultColor defaultBackground">{"color": "-moz-ComboboxText", "backgroundColor": "transparent", "unstyled": "true"}</option>' +
+  '  <option value="Five" class="defaultColor">{"color": "-moz-ComboboxText", "backgroundColor": "transparent", "unstyled": "true"}</option>' +
+  '  <option value="Six" class="defaultBackground">{"color": "-moz-ComboboxText", "backgroundColor": "transparent", "unstyled": "true"}</option>' +
   "</select></body></html>";
 
 function openSelectPopup(selectPopup, mode = "key", selector = "select", win = window) {
   let popupShownPromise = BrowserTestUtils.waitForEvent(selectPopup, "popupshown");
 
   if (mode == "click" || mode == "mousedown") {
     let mousePromise;
     if (mode == "click") {
@@ -733,86 +741,58 @@ add_task(function* test_somehidden() {
     child = child.nextSibling;
   }
 
   yield hideSelectPopup(selectPopup, "escape");
   yield BrowserTestUtils.removeTab(tab);
 });
 
 add_task(function* test_colors_applied_to_popup() {
-  function inverseRGBString(rgbString) {
-    let [, r, g, b] = rgbString.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/);
-    return `rgb(${255 - r}, ${255 - g}, ${255 - b})`;
-  }
-
   const pageUrl = "data:text/html," + escape(PAGECONTENT_COLORS);
   let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, pageUrl);
 
   let selectPopup = document.getElementById("ContentSelectDropdown").menupopup;
 
   let popupShownPromise = BrowserTestUtils.waitForEvent(selectPopup, "popupshown");
   yield BrowserTestUtils.synthesizeMouseAtCenter("#one", { type: "mousedown" }, gBrowser.selectedBrowser);
   yield popupShownPromise;
 
   // The label contains a JSON string of the expected colors for
   // `color` and `background-color`.
-  is(selectPopup.parentNode.itemCount, 3, "Correct number of items");
+  is(selectPopup.parentNode.itemCount, 6, "Correct number of items");
   let child = selectPopup.firstChild;
   let idx = 1;
 
   ok(child.selected, "The first child should be selected");
   while (child) {
-    let expectedColors = JSON.parse(child.label);
-
-    // We need to use Canvas here to get the actual pixel color
-    // because the computedStyle will only tell us the 'color' or
-    // 'backgroundColor' of the element, but not what the displayed
-    // color is due to composition of various CSS rules such as
-    // 'filter' which is applied when elements have custom background
-    // or foreground elements.
-    let canvas = document.createElementNS("http://www.w3.org/1999/xhtml", "canvas");
-    canvas = document.documentElement.appendChild(canvas);
-    let rect = child.getBoundingClientRect();
-    canvas.setAttribute("width", rect.width);
-    canvas.setAttribute("height", rect.height);
-    canvas.mozOpaque = true;
+    let expected = JSON.parse(child.label);
 
-    let ctx = canvas.getContext("2d");
-    ctx.drawWindow(window, rect.x + rect.left, rect.y + rect.top, rect.width, rect.height, "#000", ctx.DRAWWINDOW_USE_WIDGET_LAYERS);
-    let frame = ctx.getImageData(0, 0, rect.width, rect.height);
-
-    let pixels = frame.data.length / 4;
-    // Assume the inverse backgroundColor is the color of the first pixel.
-    let [inverseBgR, inverseBgG, inverseBgB] = frame.data;
-    let inverseBackgroundColor = `rgb(${inverseBgR}, ${inverseBgG}, ${inverseBgB})`;
-    // Use the next different pixel color as the foreground color, assuming
-    // no anti-aliasing.
-    let inverseColor = inverseBackgroundColor;
-    for (let i = 0; i < pixels; i++) {
-      if (inverseBgR != frame.data[i * 4 + 0] &&
-          inverseBgG != frame.data[i * 4 + 1] &&
-          inverseBgB != frame.data[i * 4 + 2]) {
-        inverseColor = `rgb(${frame.data[i * 4 + 0]}, ${frame.data[i * 4 + 1]}, ${frame.data[i * 4 + 2]})`;
+    for (let color of Object.keys(expected)) {
+      if (color.toLowerCase().includes("color") &&
+          !expected[color].startsWith("rgb")) {
+        // Need to convert system color to RGB color.
+        let textarea = document.createElementNS("http://www.w3.org/1999/xhtml", "textarea");
+        textarea.style.color = expected[color];
+        expected[color] = getComputedStyle(textarea).color;
       }
     }
-    // The canvas code above isn't getting the right colors for the pixels,
-    // it always returns rgb(255,255,255).
-    todo_is(inverseColor, inverseRGBString(getComputedStyle(child).color),
-      "Item " + (idx) + " has correct inverse foreground color when selected");
-    todo_is(inverseBackgroundColor, inverseRGBString(getComputedStyle(child).backgroundColor),
-      "Item " + (idx) + " has correct inverse background color when selected");
-
-    canvas.remove();
 
     // Press Down to move the selected item to the next item in the
     // list and check the colors of this item when it's not selected.
     EventUtils.synthesizeKey("KEY_ArrowDown", { code: "ArrowDown" });
 
-    is(getComputedStyle(child).color, expectedColors.color,
-       "Item " + (idx) + " has correct foreground color");
-    is(getComputedStyle(child).backgroundColor, expectedColors.backgroundColor,
-       "Item " + (idx++) + " has correct background color");
+    if (expected.unstyled) {
+      ok(!child.hasAttribute("customoptionstyling"),
+        `Item ${idx} should not have any custom option styling`);
+    } else {
+      is(getComputedStyle(child).color, expected.color,
+         "Item " + (idx) + " has correct foreground color");
+      is(getComputedStyle(child).backgroundColor, expected.backgroundColor,
+         "Item " + (idx) + " has correct background color");
+    }
+
+    idx++;
     child = child.nextSibling;
   }
 
   yield hideSelectPopup(selectPopup, "escape");
   yield BrowserTestUtils.removeTab(tab);
 });
--- a/toolkit/content/widgets/browser.xml
+++ b/toolkit/content/widgets/browser.xml
@@ -1063,17 +1063,18 @@
             case "Forms:ShowDropDown": {
               if (!this._selectParentHelper) {
                 this._selectParentHelper =
                   Cu.import("resource://gre/modules/SelectParentHelper.jsm", {}).SelectParentHelper;
               }
 
               let menulist = document.getElementById(this.getAttribute("selectmenulist"));
               menulist.menupopup.style.direction = data.direction;
-              this._selectParentHelper.populate(menulist, data.options, data.selectedIndex, this._fullZoom);
+              this._selectParentHelper.populate(menulist, data.options, data.selectedIndex, this._fullZoom,
+                                                data.uaBackgroundColor, data.uaColor);
               this._selectParentHelper.open(this, menulist, data.rect, data.isOpenedViaTouch);
               break;
             }
 
             case "Forms:HideDropDown": {
               if (this._selectParentHelper) {
                 let menulist = document.getElementById(this.getAttribute("selectmenulist"));
                 this._selectParentHelper.hide(menulist, this);
--- a/toolkit/content/widgets/remote-browser.xml
+++ b/toolkit/content/widgets/remote-browser.xml
@@ -466,17 +466,18 @@
                   Cu.import("resource://gre/modules/SelectParentHelper.jsm", {}).SelectParentHelper;
               }
 
               let menulist = document.getElementById(this.getAttribute("selectmenulist"));
               menulist.menupopup.style.direction = data.direction;
 
               let zoom = Services.prefs.getBoolPref("browser.zoom.full") ||
                          this.isSyntheticDocument ? this._fullZoom : this._textZoom;
-              this._selectParentHelper.populate(menulist, data.options, data.selectedIndex, zoom);
+              this._selectParentHelper.populate(menulist, data.options, data.selectedIndex,
+                                                zoom, data.uaBackgroundColor, data.uaColor);
               this._selectParentHelper.open(this, menulist, data.rect, data.isOpenedViaTouch);
               break;
             }
 
             case "FullZoomChange": {
               this._fullZoom = data.value;
               let event = document.createEvent("Events");
               event.initEvent("FullZoomChange", true, false);
--- a/toolkit/modules/SelectContentHelper.jsm
+++ b/toolkit/modules/SelectContentHelper.jsm
@@ -31,16 +31,18 @@ this.EXPORTED_SYMBOLS = [
 ];
 
 this.SelectContentHelper = function(aElement, aOptions, aGlobal) {
   this.element = aElement;
   this.initialSelection = aElement[aElement.selectedIndex] || null;
   this.global = aGlobal;
   this.closedWithEnter = false;
   this.isOpenedViaTouch = aOptions.isOpenedViaTouch;
+  this._uaBackgroundColor = null;
+  this._uaColor = null;
   this.init();
   this.showDropDown();
   this._updateTimer = new DeferredTask(this._update.bind(this), 0);
 }
 
 Object.defineProperty(SelectContentHelper, "open", {
   get() {
     return gOpen;
@@ -86,17 +88,19 @@ this.SelectContentHelper.prototype = {
   showDropDown() {
     this.element.openInParentProcess = true;
     let rect = this._getBoundingContentRect();
     this.global.sendAsyncMessage("Forms:ShowDropDown", {
       rect,
       options: this._buildOptionList(),
       selectedIndex: this.element.selectedIndex,
       direction: getComputedStyles(this.element).direction,
-      isOpenedViaTouch: this.isOpenedViaTouch
+      isOpenedViaTouch: this.isOpenedViaTouch,
+      uaBackgroundColor: this.uaBackgroundColor,
+      uaColor: this.uaColor,
     });
     gOpen = true;
   },
 
   _getBoundingContentRect() {
     return BrowserUtils.getElementBoundingScreenRect(this.element);
   },
 
@@ -105,19 +109,47 @@ this.SelectContentHelper.prototype = {
   },
 
   _update() {
     // The <select> was updated while the dropdown was open.
     // Let's send up a new list of options.
     this.global.sendAsyncMessage("Forms:UpdateDropDown", {
       options: this._buildOptionList(),
       selectedIndex: this.element.selectedIndex,
+      uaBackgroundColor: this.uaBackgroundColor,
+      uaColor: this.uaColor,
     });
   },
 
+  // Determine user agent background-color and color.
+  // This is used to skip applying the custom color if it matches
+  // the user agent values.
+  _calculateUAColors() {
+    let dummy = this.element.ownerDocument.createElement("option");
+    dummy.style.color = "-moz-comboboxtext";
+    dummy.style.backgroundColor = "-moz-combobox";
+    let dummyCS = this.element.ownerGlobal.getComputedStyle(dummy);
+    this._uaBackgroundColor = dummyCS.backgroundColor;
+    this._uaColor = dummyCS.color;
+  },
+
+  get uaBackgroundColor() {
+    if (!this._uaBackgroundColor) {
+      this._calculateUAColors();
+    }
+    return this._uaBackgroundColor;
+  },
+
+  get uaColor() {
+    if (!this._uaColor) {
+      this._calculateUAColors();
+    }
+    return this._uaColor;
+  },
+
   dispatchMouseEvent(win, target, eventName) {
     let mouseEvent = new win.MouseEvent(eventName, {
       view: win,
       bubbles: true,
       cancelable: true,
     });
     target.dispatchEvent(mouseEvent);
   },
--- a/toolkit/modules/SelectParentHelper.jsm
+++ b/toolkit/modules/SelectParentHelper.jsm
@@ -20,22 +20,23 @@ const SEARCH_MINIMUM_ELEMENTS = 40;
 
 var currentBrowser = null;
 var currentMenulist = null;
 var currentZoom = 1;
 var closedWithEnter = false;
 var selectRect;
 
 this.SelectParentHelper = {
-  populate(menulist, items, selectedIndex, zoom) {
+  populate(menulist, items, selectedIndex, zoom, uaBackgroundColor, uaColor) {
     // Clear the current contents of the popup
     menulist.menupopup.textContent = "";
     currentZoom = zoom;
     currentMenulist = menulist;
-    populateChildren(menulist, items, selectedIndex, zoom);
+    populateChildren(menulist, items, selectedIndex, zoom,
+                     uaBackgroundColor, uaColor);
   },
 
   open(browser, menulist, rect, isOpenedViaTouch) {
     menulist.hidden = false;
     currentBrowser = browser;
     closedWithEnter = false;
     selectRect = rect;
     this._registerListeners(browser, menulist.menupopup);
@@ -134,17 +135,20 @@ this.SelectParentHelper = {
       // Sanity check - we'd better know what the currently
       // opened menulist is, and what browser it belongs to...
       if (!currentMenulist || !currentBrowser) {
         return;
       }
 
       let options = msg.data.options;
       let selectedIndex = msg.data.selectedIndex;
-      this.populate(currentMenulist, options, selectedIndex, currentZoom);
+      let uaBackgroundColor = msg.data.uaBackgroundColor;
+      let uaColor = msg.data.uaColor;
+      this.populate(currentMenulist, options, selectedIndex,
+                    currentZoom, uaBackgroundColor, uaColor);
     }
   },
 
   _registerListeners(browser, popup) {
     popup.addEventListener("command", this);
     popup.addEventListener("popuphidden", this);
     popup.addEventListener("mouseover", this);
     popup.addEventListener("mouseout", this);
@@ -163,25 +167,25 @@ this.SelectParentHelper = {
     browser.ownerGlobal.removeEventListener("keydown", this, true);
     browser.ownerGlobal.removeEventListener("fullscreen", this, true);
     browser.messageManager.removeMessageListener("Forms:UpdateDropDown", this);
   },
 
 };
 
 function populateChildren(menulist, options, selectedIndex, zoom,
+                          uaBackgroundColor, uaColor,
                           parentElement = null, isGroupDisabled = false,
                           adjustedTextSize = -1, addSearch = true) {
   let element = menulist.menupopup;
+  let win = element.ownerGlobal;
 
   // -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.ownerGlobal;
-
     // Grab the computed text size and multiply it by the remote browser's fullZoom to ensure
     // the popup's text size is matched with the content's. We can't just apply a CSS transform
     // here as the popup's preferred size is calculated pre-transform.
     let textSize = win.getComputedStyle(element).getPropertyValue("font-size");
     adjustedTextSize = (zoom * parseFloat(textSize, 10)) + "px";
   }
 
   for (let option of options) {
@@ -192,41 +196,47 @@ function populateChildren(menulist, opti
     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);
 
-    if (option.backgroundColor && option.backgroundColor != "transparent") {
+    let customOptionStylingUsed = false;
+    if (option.backgroundColor &&
+        option.backgroundColor != "transparent" &&
+        option.backgroundColor != uaBackgroundColor) {
       item.style.backgroundColor = option.backgroundColor;
+      customOptionStylingUsed = true;
     }
 
-    if (option.color && option.color != "transparent") {
+    if (option.color &&
+        option.color != uaColor) {
       item.style.color = option.color;
+      customOptionStylingUsed = true;
     }
 
-    if ((option.backgroundColor && option.backgroundColor != "transparent") ||
-        (option.color && option.color != "rgb(0, 0, 0)")) {
+    if (customOptionStylingUsed) {
       item.setAttribute("customoptionstyling", "true");
     } else {
       item.removeAttribute("customoptionstyling");
     }
 
     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,
+                       uaBackgroundColor, uaColor,
                        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>.
--- a/toolkit/themes/linux/global/menu.css
+++ b/toolkit/themes/linux/global/menu.css
@@ -27,16 +27,20 @@ menuitem[default="true"] {
 }
 
 menu[_moz-menuactive="true"],
 menuitem[_moz-menuactive="true"] {
   color: -moz-menuhovertext;
   background-color: -moz-menuhover;
 }
 
+menuitem[customoptionstyling="true"] {
+  -moz-appearance: none;
+}
+
 menuitem[_moz-menuactive="true"][customoptionstyling="true"] {
   filter: invert(100%);
 }
 
 menu[disabled="true"],
 menuitem[disabled="true"],
 menucaption[disabled="true"] {
   color: GrayText;