Bug 910022 - Allow websites to provide custom background colors and foreground colors for <select> popups. r?enndeakin draft
authorJared Wein <jwein@mozilla.com>
Mon, 30 Jan 2017 17:35:14 -0500
changeset 468239 787850db58dce51022026586f11e4c6cca3de352
parent 468005 f7e1982a2582b14c5885d787b530f879da3a040e
child 468593 c94e6dfb1045817dbbb848609d03e166c9de22c8
child 468601 cb77c3ff9fd02cd59aabe706f5fdb4c9ae24a9a6
child 468613 733ff7f8bcda8756f45bb6b0aadc1035f0e31c84
child 469669 14c5be3d53c36d2ccb0579bdd0d8d3e2d2589288
child 469722 ef293413adf86e4f502094e6412995b93fa6e5fb
child 470278 7013dc4af5e450236e6f65fed13de9df3db37398
push id43393
push userbmo:jaws@mozilla.com
push dateMon, 30 Jan 2017 22:41:07 +0000
reviewersenndeakin
bugs910022
milestone54.0a1
Bug 910022 - Allow websites to provide custom background colors and foreground colors for <select> popups. r?enndeakin Portions of the patch were written by Jared Beach (beachjar@msu.edu) MozReview-Commit-ID: 1SpUTJP8tPq
browser/base/content/test/general/browser_selectpopup.js
toolkit/modules/SelectContentHelper.jsm
toolkit/modules/SelectParentHelper.jsm
toolkit/themes/linux/global/menu.css
toolkit/themes/osx/global/menu.css
toolkit/themes/windows/global/menu.css
--- a/browser/base/content/test/general/browser_selectpopup.js
+++ b/browser/base/content/test/general/browser_selectpopup.js
@@ -75,16 +75,24 @@ 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>";
 
+const PAGECONTENT_COLORS =
+  "<html><head><style>.blue { color: #fff; background-color: #00f; } .green { color: #800080; background-color: green; }</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>' +
+  "</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") {
       mousePromise = BrowserTestUtils.synthesizeMouseAtCenter(selector, { }, win.gBrowser.selectedBrowser);
     } else {
@@ -724,8 +732,87 @@ add_task(function* test_somehidden() {
        "Item " + (idx++) + " is visible");
     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");
+  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 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]})`;
+      }
+    }
+    // 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");
+    child = child.nextSibling;
+  }
+
+  yield hideSelectPopup(selectPopup, "escape");
+  yield BrowserTestUtils.removeTab(tab);
+});
--- a/toolkit/modules/SelectContentHelper.jsm
+++ b/toolkit/modules/SelectContentHelper.jsm
@@ -220,34 +220,39 @@ function buildOptionListForChildren(node
 
       let textContent =
         tagName == "OPTGROUP" ? child.getAttribute("label")
                               : child.text;
       if (textContent == null) {
         textContent = "";
       }
 
+      // Selected options have the :checked pseudo-class, which
+      // we want to disable before calculating the computed
+      // styles since the user agent styles alter the styling
+      // based on :checked.
+      DOMUtils.addPseudoClassLock(child, ":checked", false);
       let cs = getComputedStyles(child);
 
       let info = {
         index: child.index,
         tagName,
         textContent,
         disabled: child.disabled,
         display: cs.display,
         // We need to do this for every option element as each one can have
         // an individual style set for direction
         textDirection: cs.direction,
         tooltip: child.title,
-        // XXX this uses a highlight color when this is the selected element.
-        // We need to suppress such highlighting in the content process to get
-        // the option's correct unhighlighted color here.
-        // We also need to detect default color vs. custom so that a standard
-        // color does not override color: menutext in the parent.
-        // backgroundColor: computedStyle.backgroundColor,
-        // color: computedStyle.color,
+        backgroundColor: cs.backgroundColor,
+        color: cs.color,
         children: tagName == "OPTGROUP" ? buildOptionListForChildren(child) : []
       };
+
+      // We must wait until all computedStyles have been
+      // read before we clear the locks.
+      DOMUtils.clearPseudoClassLocks(child);
+
       result.push(info);
     }
   }
   return result;
 }
--- a/toolkit/modules/SelectParentHelper.jsm
+++ b/toolkit/modules/SelectParentHelper.jsm
@@ -192,16 +192,31 @@ 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") {
+      item.style.backgroundColor = option.backgroundColor;
+    }
+
+    if (option.color && option.color != "transparent") {
+      item.style.color = option.color;
+    }
+
+    if ((option.backgroundColor && option.backgroundColor != "transparent") ||
+        (option.color && option.color != "rgb(0, 0, 0)")) {
+      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");
     }
 
--- 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[_moz-menuactive="true"][customoptionstyling="true"] {
+  filter: invert(100%);
+}
+
 menu[disabled="true"],
 menuitem[disabled="true"],
 menucaption[disabled="true"] {
   color: GrayText;
 }
 
 menubar > menu {
   padding: 0px 4px;
--- a/toolkit/themes/osx/global/menu.css
+++ b/toolkit/themes/osx/global/menu.css
@@ -130,16 +130,26 @@ menupopup > menucaption {
 }
 
 menu[_moz-menuactive="true"],
 menuitem[_moz-menuactive="true"] {
   color: -moz-mac-menutextselect;
   background-color: Highlight;
 }
 
+menuitem[customoptionstyling="true"] {
+  -moz-appearance: none;
+  padding-top: 0;
+  padding-bottom: 0;
+}
+
+menuitem[_moz-menuactive="true"][customoptionstyling="true"] {
+  filter: invert(100%);
+}
+
 /* ::::: menu/menuitems in menulist popups ::::: */
 
 menulist > menupopup > menuitem,
 menulist > menupopup > menucaption,
 menulist > menupopup > menu {
   max-width: none;
   font: inherit;
   color: -moz-FieldText;
--- a/toolkit/themes/windows/global/menu.css
+++ b/toolkit/themes/windows/global/menu.css
@@ -199,16 +199,20 @@ menulist > menupopup > menu {
 }
 
 menulist > menupopup > menuitem[_moz-menuactive="true"],
 menulist > menupopup > menu[_moz-menuactive="true"] {
   background-color: highlight;
   color: highlighttext;
 }
 
+menulist > menupopup > menuitem[_moz-menuactive="true"][customoptionstyling="true"] {
+  filter: invert(100%);
+}
+
 menulist > menupopup > menuitem > .menu-iconic-left,
 menulist > menupopup > menucaption > .menu-iconic-left,
 menulist > menupopup > menu > .menu-iconic-left {
   display: none;
 }
 
 menulist > menupopup > menuitem > label,
 menulist > menupopup > menucaption > label,