Bug 1516647 - Migrate facet-discrete to custom element. r=mkmelin
authorArshad Khan <arshdkhn1@gmail.com>
Fri, 28 Dec 2018 22:17:24 +0530
changeset 33307 53838da2d151
parent 33306 0a09c6d031d8
child 33308 0c6c2007dfae
push id2368
push userclokep@gmail.com
push dateMon, 28 Jan 2019 21:12:50 +0000
treeherdercomm-beta@56d23c07d815 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmkmelin
bugs1516647
Bug 1516647 - Migrate facet-discrete to custom element. r=mkmelin
mail/base/content/glodaFacet.js
mail/base/content/glodaFacetBindings.css
mail/base/content/glodaFacetBindings.xml
mail/base/content/glodaFacetView.js
--- a/mail/base/content/glodaFacet.js
+++ b/mail/base/content/glodaFacet.js
@@ -1,20 +1,20 @@
 /* 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/. */
 
-/* global HTMLElement, DateFacetVis, FacetContext, glodaFacetStrings */
+/* global HTMLElement, DateFacetVis, FacetContext, glodaFacetStrings, FacetUtils, PluralForm, logException */
 class MozFacetDate extends HTMLElement {
   get build() {
     return this.buildFunc;
   }
 
   get brushItems() {
-    return (aItems) => this.vis.hoverItems(aItems);
+    return (items) => this.vis.hoverItems(items);
   }
 
   get clearBrushedItems() {
     return () => this.vis.clearHover();
   }
 
   connectedCallback() {
     const wrapper = document.createElement("div");
@@ -380,12 +380,584 @@ class MozFacetBooleanFiltered extends Mo
     } else {
       let groupValue = this.realTrueGroups[parseInt(this.filterNode.value)];
       this.selectedValue = groupValue.category;
       FacetContext.addFacetConstraint(this.faceter, true, [groupValue], false, true);
     }
   }
 }
 
+class MozFacetDiscrete extends HTMLElement {
+  constructor() {
+    super();
+
+    this.addEventListener("click", (event) => { this.showPopup(event); });
+
+    this.addEventListener("keypress", (event) => {
+      if (event.keyCode != KeyEvent.DOM_VK_RETURN) {
+        return;
+      }
+      this.showPopup(event);
+    });
+
+    this.addEventListener("keypress", (event) => { this.activateLink(event); });
+
+    this.addEventListener("mouseover", (event) => {
+      // we dispatch based on the class of the thing we clicked on.
+      // there are other ways we could accomplish this, but they all sorta suck.
+      if (event.originalTarget.hasAttribute("class") &&
+        event.originalTarget.classList.contains("bar-link")) {
+        this.barHovered(event.originalTarget.parentNode, true);
+      }
+    });
+
+    this.addEventListener("mouseout", (event) => {
+      // we dispatch based on the class of the thing we clicked on.
+      // there are other ways we could accomplish this, but they all sorta suck.
+      if (event.originalTarget.hasAttribute("class") &&
+        event.originalTarget.classList.contains("bar-link")) {
+        this.barHoverGone(event.originalTarget.parentNode, true);
+      }
+    });
+
+  }
+
+  connectedCallback() {
+    const facet = document.createElement("div");
+    facet.classList.add("facet");
+
+    this.nameNode = document.createElement("h2");
+
+    this.contentBox = document.createElement("div");
+    this.contentBox.classList.add("facet-content");
+
+    this.includeLabel = document.createElement("h3");
+    this.includeLabel.classList.add("facet-included-header");
+
+    this.includeList = document.createElement("ul");
+    this.includeList.classList.add("facet-included", "barry");
+
+    this.remainderLabel = document.createElement("h3");
+    this.remainderLabel.classList.add("facet-remaindered-header");
+
+    this.remainderList = document.createElement("ul");
+    this.remainderList.classList.add("facet-remaindered", "barry");
+
+    this.excludeLabel = document.createElement("h3");
+    this.excludeLabel.classList.add("facet-excluded-header");
+
+    this.excludeList = document.createElement("ul");
+    this.excludeList.classList.add("facet-excluded", "barry");
+
+    this.moreButton = document.createElement("div");
+    this.moreButton.classList.add("facet-more");
+    this.moreButton.setAttribute("needed", "false");
+    this.moreButton.setAttribute("tabindex", "0");
+    this.moreButton.setAttribute("role", "button");
+
+    this.contentBox.appendChild(this.includeLabel);
+    this.contentBox.appendChild(this.includeList);
+    this.contentBox.appendChild(this.remainderLabel);
+    this.contentBox.appendChild(this.remainderList);
+    this.contentBox.appendChild(this.excludeLabel);
+    this.contentBox.appendChild(this.excludeList);
+    this.contentBox.appendChild(this.moreButton);
+
+    facet.appendChild(this.nameNode);
+    facet.appendChild(this.contentBox);
+
+    this.appendChild(facet);
+
+    this.canUpdate = false;
+
+    if ("faceter" in this) {
+      this.build(true);
+    }
+  }
+
+  build(firstTime) {
+    // -- Header Building
+    this.nameNode.textContent = this.facetDef.strings.facetNameLabel;
+
+    // - include
+    // setup the include label
+    if ("includeLabel" in this.facetDef.strings) {
+      this.includeLabel.textContent = this.facetDef.strings.includeLabel;
+    } else {
+      this.includeLabel.textContent =
+        glodaFacetStrings.get("glodaFacetView.facets.included.fallbackLabel");
+    }
+    this.includeLabel.setAttribute("state", "empty");
+
+    // - exclude
+    // setup the exclude label
+    if ("excludeLabel" in this.facetDef.strings) {
+      this.excludeLabel.textContent = this.facetDef.strings.excludeLabel;
+    } else {
+      this.excludeLabel.textContent =
+        glodaFacetStrings.get("glodaFacetView.facets.excluded.fallbackLabel");
+    }
+    this.excludeLabel.setAttribute("state", "empty");
+
+    // - remainder
+    // setup the remainder label
+    if ("remainderLabel" in this.facetDef.strings) {
+      this.remainderLabel.textContent = this.facetDef.strings.remainderLabel;
+    } else {
+      this.remainderLabel.textContent =
+        glodaFacetStrings.get("glodaFacetView.facets.remainder.fallbackLabel");
+    }
+
+    // -- House-cleaning
+    // -- All/Top mode decision
+    this.modes = ["all"];
+    if (this.maxDisplayRows >= this.orderedGroups.length) {
+      this.mode = "all";
+    } else {
+      // top mode must be used
+      this.modes.push("top");
+      this.mode = "top";
+      this.topGroups = FacetUtils.makeTopGroups(
+        this.attrDef,
+        this.orderedGroups,
+        this.maxDisplayRows
+      );
+      // setup the more button string
+      let groupCount = this.orderedGroups.length;
+      this.moreButton.textContent =
+        PluralForm.get(
+          groupCount,
+          glodaFacetStrings.get(
+            "glodaFacetView.facets.mode.top.listAllLabel"
+          )
+        ).replace("#1", groupCount);
+    }
+
+    // -- Row Building
+    this.buildRows();
+  }
+
+  changeMode(newMode) {
+    this.mode = newMode;
+    this.setAttribute("mode", newMode);
+    this.buildRows();
+  }
+
+  buildRows() {
+    let nounDef = this.nounDef;
+    let useGroups = (this.mode == "all") ? this.orderedGroups : this.topGroups;
+
+    // should we just rely on automatic string coercion?
+    this.moreButton.setAttribute("needed", (this.mode == "top") ? "true" : "false");
+
+    let constraint = this.faceter.constraint;
+
+    // -- empty all of our display buckets...
+    let remainderList = this.remainderList;
+    while (remainderList.hasChildNodes()) {
+      remainderList.lastChild.remove();
+    }
+    let includeList = this.includeList;
+    let excludeList = this.excludeList;
+    while (includeList.hasChildNodes()) {
+      includeList.lastChild.remove();
+    }
+    while (excludeList.hasChildNodes()) {
+      excludeList.lastChild.remove();
+    }
+
+    // -- first pass, check for ambiguous labels
+    // It's possible that multiple groups are identified by the same short
+    //  string, in which case we want to use the longer string to
+    //  disambiguate.  For example, un-merged contacts can result in
+    //  multiple identities having contacts with the same name.  In that
+    //  case we want to display both the contact name and the identity
+    //  name.
+    // This is generically addressed by using the userVisibleString function
+    //  defined on the noun type if it is defined.  It takes an argument
+    //  indicating whether it should be a short string or a long string.
+    // Our algorithm is somewhat dumb.  We get the short strings, put them
+    //  in a dictionary that maps to whether they are ambiguous or not.  We
+    //  do not attempt to map based on their id, so then when it comes time
+    //  to actually build the labels, we must build the short string and
+    //  then re-call for the long name.  We could be smarter by building
+    //  a list of the input values that resulted in the output string and
+    //  then using that to back-update the id map, but it's more compelx and
+    //  the performance difference is unlikely to be meaningful.
+    let ambiguousKeyValues;
+    if ("userVisibleString" in nounDef) {
+      ambiguousKeyValues = {};
+      for (let groupPair of useGroups) {
+        let [groupValue] = groupPair;
+
+        // skip null values, they are handled by the none special-case
+        if (groupValue == null) {
+          continue;
+        }
+
+        let groupStr = nounDef.userVisibleString(groupValue, false);
+        // We use hasOwnProperty because it is possible that groupStr could
+        //  be the same as the name of one of the attributes on
+        //  Object.prototype.
+        if (ambiguousKeyValues.hasOwnProperty(groupStr)) {
+          ambiguousKeyValues[groupStr] = true;
+        } else {
+          ambiguousKeyValues[groupStr] = false;
+        }
+      }
+    }
+
+    // -- create the items, assigning them to the right list based on
+    //  existing constraint values
+    for (let groupPair of useGroups) {
+      let [groupValue, groupItems] = groupPair;
+      let li = document.createElement("li");
+      li.setAttribute("class", "bar");
+      li.setAttribute("tabindex", "0");
+      li.setAttribute("role", "link");
+      li.setAttribute("aria-haspopup", "true");
+      li.groupValue = groupValue;
+      li.setAttribute("groupValue", groupValue);
+      li.groupItems = groupItems;
+
+      let countSpan = document.createElement("span");
+      countSpan.setAttribute("class", "bar-count");
+      countSpan.textContent = groupItems.length.toLocaleString();
+      li.appendChild(countSpan);
+
+      let label = document.createElement("span");
+      label.setAttribute("class", "bar-link");
+
+      // The null value is a special indicator for 'none'
+      if (groupValue == null) {
+        label.textContent = glodaFacetStrings.get("glodaFacetView.facets.noneLabel");
+      } else {
+        // Otherwise stringify the group object
+        let labelStr;
+        if (ambiguousKeyValues) {
+          labelStr = nounDef.userVisibleString(groupValue, false);
+          if (ambiguousKeyValues[labelStr]) {
+            labelStr = nounDef.userVisibleString(groupValue, true);
+          }
+        } else if ("labelFunc" in this.facetDef) {
+          labelStr = this.facetDef.labelFunc(groupValue);
+        } else {
+          labelStr = groupValue.toLocaleString().substring(0, 80);
+        }
+        label.textContent = labelStr;
+        label.setAttribute("title", labelStr);
+      }
+      li.appendChild(label);
+
+      // root it under the appropriate list
+      if (constraint) {
+        if (constraint.isIncludedGroup(groupValue)) {
+          li.setAttribute("variety", "include");
+          includeList.appendChild(li);
+        } else if (constraint.isExcludedGroup(groupValue)) {
+          li.setAttribute("variety", "exclude");
+          excludeList.appendChild(li);
+        } else {
+          li.setAttribute("variety", "remainder");
+          remainderList.appendChild(li);
+        }
+      } else {
+        li.setAttribute("variety", "remainder");
+        remainderList.appendChild(li);
+      }
+    }
+
+    this.updateHeaderStates();
+  }
+
+  /**
+   * - Mark the include/exclude headers as "some" if there is anything in their
+   * - lists, mark the remainder header as "needed" if either of include /
+   * - exclude exist so we need that label.
+   */
+  updateHeaderStates(items) {
+    this.includeLabel.setAttribute("state", this.includeList.childElementCount ? "some" : "empty");
+    this.excludeLabel.setAttribute("state", this.excludeList.childElementCount ? "some" : "empty");
+    this.remainderLabel.setAttribute("needed",
+      (
+        (
+          this.includeList.childElementCount ||
+          this.excludeList.childElementCount
+        ) &&
+        this.remainderList.childElementCount) ? "true" : "false"
+    );
+
+    // nuke the style attributes.
+    this.includeLabel.removeAttribute("style");
+    this.excludeLabel.removeAttribute("style");
+    this.remainderLabel.removeAttribute("style");
+  }
+
+  brushItems(items) { }
+
+  clearBrushedItems() { }
+
+  afterListVisible(variety, callback) {
+    let labelNode = this[variety + "Label"];
+    let listNode = this[variety + "List"];
+
+    // if there are already things displayed, no need
+    if (listNode.childElementCount) {
+      callback();
+      return;
+    }
+
+    let remListVisible =
+      this.remainderLabel.getAttribute("needed") == "true";
+    let remListShouldBeVisible =
+      this.remainderList.childElementCount > 1;
+
+    labelNode.setAttribute("state", "some");
+
+    let showNodes = [labelNode];
+    if (remListVisible != remListShouldBeVisible) {
+      showNodes = [labelNode, this.remainderLabel];
+    }
+
+    showNodes.forEach(node => node.style.display = "block");
+
+    callback();
+  }
+
+  _flyBarAway(barNode, variety, callback) {
+    function getRect(aElement) {
+      let box = aElement.getBoundingClientRect();
+      let documentElement = aElement.ownerDocument.documentElement;
+      return {
+        top: box.top + window.pageYOffset - documentElement.clientTop,
+        left: box.left + window.pageXOffset - documentElement.clientLeft,
+        width: box.width,
+        height: box.height,
+      };
+    }
+    // figure out our origin location prior to adding the target or it
+    //  will shift us down.
+    let origin = getRect(barNode);
+
+    // clone the node into its target location
+    let targetNode = barNode.cloneNode(true);
+    targetNode.groupValue = barNode.groupValue;
+    targetNode.groupItems = barNode.groupItems;
+    targetNode.setAttribute("variety", variety);
+
+    let targetParent = this[variety + "List"];
+    targetParent.appendChild(targetNode);
+
+    // create a flying clone
+    let flyingNode = barNode.cloneNode(true);
+
+    let dest = getRect(targetNode);
+
+    // if the flying box wants to go higher than the content box goes, just
+    //  send it to the top of the content box instead.
+    let contentRect = getRect(this.contentBox);
+    if (dest.top < contentRect.top) {
+      dest.top = contentRect.top;
+    }
+
+    // likewise if it wants to go further south than the content box, stop
+    //  that
+    if (dest.top > (contentRect.top + contentRect.height)) {
+      dest.top = contentRect.top + contentRect.height - dest.height;
+    }
+
+    flyingNode.style.position = "absolute";
+    flyingNode.style.width = origin.width + "px";
+    flyingNode.style.height = origin.height + "px";
+    flyingNode.style.top = origin.top + "px";
+    flyingNode.style.left = origin.left + "px";
+    flyingNode.style.zIndex = 1000;
+
+    flyingNode.style.transitionDuration = (Math.abs(dest.top - origin.top) * 2) + "ms";
+    flyingNode.style.transitionProperty = "top, left";
+
+    flyingNode.addEventListener("transitionend", () => {
+      barNode.remove();
+      targetNode.style.display = "block";
+      flyingNode.remove();
+
+      if (callback) {
+        setTimeout(callback, 50);
+      }
+    });
+
+    document.body.appendChild(flyingNode);
+
+    setTimeout(() => {
+      // animate the flying clone... flying!
+      window.requestAnimationFrame(() => {
+        flyingNode.style.top = dest.top + "px";
+        flyingNode.style.left = dest.left + "px";
+      });
+
+      // hide the target (cloned) node
+      targetNode.style.display = "none";
+
+      // hide the original node and remove its JS properties
+      barNode.style.visibility = "hidden";
+      delete barNode.groupValue;
+      delete barNode.groupItems;
+    }, 0);
+  }
+
+  barClicked(barNode, variety) {
+    let groupValue = barNode.groupValue;
+    // These determine what goAnimate actually does.
+    // flyAway allows us to cancel flying in the case the constraint is
+    //  being fully dropped and so the facet is just going to get rebuilt
+    let flyAway = true;
+
+    const goAnimate = () => {
+      setTimeout(() => {
+        if (flyAway) {
+          this.afterListVisible(variety, () => {
+            this._flyBarAway(barNode, variety, () => {
+              this.updateHeaderStates();
+            });
+          });
+        }
+      }, 0);
+    };
+
+    // Immediately apply the facet change, triggering the animation after
+    //  the faceting completes.
+    if (variety == "remainder") {
+      let currentVariety = barNode.getAttribute("variety");
+      let constraintGone = FacetContext.removeFacetConstraint(
+        this.faceter,
+        currentVariety == "include", [groupValue],
+        goAnimate
+      );
+
+      // we will automatically rebuild if the constraint is gone, so
+      //  just make the animation a no-op.
+      if (constraintGone) {
+        flyAway = false;
+      }
+    } else { // include/exclude
+      let revalidate = FacetContext.addFacetConstraint(
+        this.faceter,
+        variety == "include", [groupValue],
+        false, false, goAnimate
+      );
+
+      // revalidate means we need to blow away the other dudes, in which
+      //  case it makes the most sense to just trigger a rebuild of ourself
+      if (revalidate) {
+        flyAway = false;
+        this.build(false);
+      }
+    }
+  }
+
+  barHovered(barNode, aInclude) {
+    let groupValue = barNode.groupValue;
+    let groupItems = barNode.groupItems;
+
+    FacetContext.hoverFacet(this.faceter, this.attrDef, groupValue, groupItems);
+  }
+
+  /**
+   * HoverGone! HoverGone!
+   * We know it's gone, but where has it gone?
+   */
+  barHoverGone(barNode, include) {
+    let groupValue = barNode.groupValue;
+    let groupItems = barNode.groupItems;
+
+    FacetContext.unhoverFacet(this.faceter, this.attrDef, groupValue, groupItems);
+  }
+
+  includeFacet(node) {
+    this.barClicked(
+      node,
+      (node.getAttribute("variety") == "remainder") ? "include" : "remainder"
+    );
+  }
+
+  undoFacet(node) {
+    this.barClicked(
+      node,
+      (node.getAttribute("variety") == "remainder") ? "include" : "remainder"
+    );
+  }
+
+  excludeFacet(node) {
+    this.barClicked(node, "exclude");
+  }
+
+  showPopup(event) {
+    try {
+      // event.originalTarget could be the <li> node, or a span inside
+      // of it, or perhaps the facet-more button, or maybe something
+      // else that we'll handle in the next version.  We walk up its
+      // parent chain until we get to the right level of the DOM
+      // hierarchy, or the facet-content which seems to be the root.
+      if (this.currentNode) {
+        this.currentNode.removeAttribute("selected");
+      }
+
+      let node = event.originalTarget;
+
+      while (
+        (!(node && node.hasAttribute && node.hasAttribute("class"))) ||
+        (
+          !node.classList.contains("bar") &&
+          !node.classList.contains("facet-more") &&
+          !node.classList.contains("facet-content")
+        )
+      ) {
+        node = node.parentNode;
+      }
+
+      if (!(node && node.hasAttribute && node.hasAttribute("class"))) {
+        return false;
+      }
+
+      this.currentNode = node;
+      node.setAttribute("selected", "true");
+
+      if (node.classList.contains("bar")) {
+        document.getElementById("popup-menu").show(event, this, node);
+      } else if (node.classList.contains("facet-more")) {
+        this.changeMode("all");
+      }
+
+      return false;
+    } catch (e) {
+      return logException(e);
+    }
+  }
+
+  activateLink(event) {
+    try {
+      let node = event.originalTarget;
+
+      while (
+        !node.hasAttribute("class") ||
+        (
+          !node.classList.contains("facet-more") &&
+          !node.classList.contains("facet-content")
+        )
+      ) {
+        node = node.parentNode;
+      }
+
+      if (node.classList.contains("facet-more")) {
+        this.changeMode("all");
+      }
+
+      return false;
+    } catch (e) {
+      return logException(e);
+    }
+  }
+}
 
 customElements.define("facet-date", MozFacetDate);
 customElements.define("facet-boolean", MozFacetBoolean);
 customElements.define("facet-boolean-filtered", MozFacetBooleanFiltered);
+customElements.define("facet-discrete", MozFacetDiscrete);
--- a/mail/base/content/glodaFacetBindings.css
+++ b/mail/base/content/glodaFacetBindings.css
@@ -1,16 +1,12 @@
 /* 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/. */
 
-.facetious[type="discrete"] {
-  -moz-binding: url('chrome://messenger/content/glodaFacetBindings.xml#facet-discrete');
-}
-
 .results[type="message"] {
   -moz-binding: url('chrome://messenger/content/glodaFacetBindings.xml#results-message');
 }
 
 .message {
   -moz-binding: url('chrome://messenger/content/glodaFacetBindings.xml#result-message');
 }
 
--- a/mail/base/content/glodaFacetBindings.xml
+++ b/mail/base/content/glodaFacetBindings.xml
@@ -229,602 +229,16 @@
         this.facetNode.undoFacet(this.node);
         this.hide();
       ]]>
       </body>
     </method>
   </implementation>
 </binding>
 
-<binding id="facet-discrete">
-  <content>
-  <!-- without this explicit div here, the sibling selectors used to span
-       included-label/included and excluded-label/excluded fail to apply.
-       so. magic! (this is why our binding node's class is facetious. -->
-  <html:div class="facet">
-    <html:h2 anonid="name"></html:h2>
-    <html:div anonid="content-box" class="facet-content">
-      <html:h3 anonid="included-label" class="facet-included-header"></html:h3>
-      <html:ul anonid="included" class="facet-included barry"></html:ul>
-      <html:h3 anonid="remainder-label" class="facet-remaindered-header"></html:h3>
-      <html:ul anonid="remainder" class="facet-remaindered barry"></html:ul>
-      <html:h3 anonid="excluded-label" class="facet-excluded-header"></html:h3>
-      <html:ul anonid="excluded" class="facet-excluded barry"></html:ul>
-      <html:div anonid="more" class="facet-more" needed="false" tabindex="0"
-                role="button"/>
-    </html:div>
-  </html:div>
-  </content>
-  <implementation>
-    <constructor><![CDATA[
-      if ("faceter" in this)
-        this.build(true);
-    ]]></constructor>
-    <field name="canUpdate" readonly="true">false</field>
-    <method name="build">
-      <parameter name="aFirstTime" />
-      <body><![CDATA[
-        // -- Header Building
-        let nameNode = document.getAnonymousElementByAttribute(this, "anonid",
-                                                               "name");
-        nameNode.textContent = this.facetDef.strings.facetNameLabel;
-
-        // - include
-        // setup the include label
-        this.includeLabel = document.getAnonymousElementByAttribute(
-                           this, "anonid", "included-label");
-        if ("includeLabel" in this.facetDef.strings)
-          this.includeLabel.textContent = this.facetDef.strings.includeLabel;
-        else
-          this.includeLabel.textContent =
-            glodaFacetStrings.get(
-              "glodaFacetView.facets.included.fallbackLabel");
-        this.includeLabel.setAttribute("state", "empty");
-
-        // include list ref
-        this.includeList = document.getAnonymousElementByAttribute(
-                                      this, "anonid", "included");
-
-        // - exclude
-        // setup the exclude label
-        this.excludeLabel = document.getAnonymousElementByAttribute(
-                                       this, "anonid", "excluded-label");
-        if ("excludeLabel" in this.facetDef.strings)
-          this.excludeLabel.textContent = this.facetDef.strings.excludeLabel;
-        else
-          this.excludeLabel.textContent =
-            glodaFacetStrings.get(
-              "glodaFacetView.facets.excluded.fallbackLabel");
-        this.excludeLabel.setAttribute("state", "empty");
-
-        // exclude list ref
-        this.excludeList = document.getAnonymousElementByAttribute(
-                                      this, "anonid", "excluded");
-
-        // - remainder
-        // setup the remainder label
-        this.remainderLabel = document.getAnonymousElementByAttribute(
-                                this, "anonid", "remainder-label");
-        if ("remainderLabel" in this.facetDef.strings)
-          this.remainderLabel.textContent = this.facetDef.strings.remainderLabel;
-        else
-          this.remainderLabel.textContent =
-            glodaFacetStrings.get(
-              "glodaFacetView.facets.remainder.fallbackLabel");
-        // remainder list ref
-        this.remainderList = document.getAnonymousElementByAttribute(
-                                        this, "anonid", "remainder");
-
-        // - more button
-        this.moreButton = document.getAnonymousElementByAttribute(
-                            this, "anonid", "more");
-
-        // we need to know who the content box is for flying fun
-        this.contentBox = document.getAnonymousElementByAttribute(
-                            this, "anonid", "content-box");
-
-
-        // -- House-cleaning
-        // -- All/Top mode decision
-        this.modes = ["all"];
-        if (this.maxDisplayRows >= this.orderedGroups.length) {
-          this.mode = "all";
-        } else {
-          // top mode must be used
-          this.modes.push("top");
-          this.mode = "top";
-          this.topGroups = FacetUtils.makeTopGroups(this.attrDef,
-                                                    this.orderedGroups,
-                                                    this.maxDisplayRows);
-          // setup the more button string
-          let groupCount = this.orderedGroups.length;
-          this.moreButton.textContent =
-            PluralForm.get(groupCount,
-                           glodaFacetStrings.get(
-                             "glodaFacetView.facets.mode.top.listAllLabel"))
-                      .replace("#1", groupCount);
-        }
-
-        // -- Row Building
-        this.buildRows();
-
-      ]]></body>
-    </method>
-    <method name="changeMode">
-      <parameter name="aNewMode" />
-      <body><![CDATA[
-        this.mode = aNewMode;
-        this.setAttribute("mode", aNewMode);
-        this.buildRows();
-      ]]></body>
-    </method>
-    <method name="buildRows">
-      <body><![CDATA[
-        let nounDef = this.nounDef;
-        let useGroups = (this.mode == "all") ? this.orderedGroups
-                                             : this.topGroups;
-
-        // should we just rely on automatic string coercion?
-        this.moreButton.setAttribute("needed",
-                                     (this.mode == "top") ? "true" : "false");
-
-        let constraint = this.faceter.constraint;
-
-        // -- empty all of our display buckets...
-        let remainderList = this.remainderList;
-        while (remainderList.hasChildNodes())
-          remainderList.lastChild.remove();
-        let includeList = this.includeList, excludeList = this.excludeList;
-        while (includeList.hasChildNodes())
-          includeList.lastChild.remove();
-        while (excludeList.hasChildNodes())
-          excludeList.lastChild.remove();
-
-        // -- first pass, check for ambiguous labels
-        // It's possible that multiple groups are identified by the same short
-        //  string, in which case we want to use the longer string to
-        //  disambiguate.  For example, un-merged contacts can result in
-        //  multiple identities having contacts with the same name.  In that
-        //  case we want to display both the contact name and the identity
-        //  name.
-        // This is generically addressed by using the userVisibleString function
-        //  defined on the noun type if it is defined.  It takes an argument
-        //  indicating whether it should be a short string or a long string.
-        // Our algorithm is somewhat dumb.  We get the short strings, put them
-        //  in a dictionary that maps to whether they are ambiguous or not.  We
-        //  do not attempt to map based on their id, so then when it comes time
-        //  to actually build the labels, we must build the short string and
-        //  then re-call for the long name.  We could be smarter by building
-        //  a list of the input values that resulted in the output string and
-        //  then using that to back-update the id map, but it's more compelx and
-        //  the performance difference is unlikely to be meaningful.
-        let ambiguousKeyValues;
-        if ("userVisibleString" in nounDef) {
-          ambiguousKeyValues = {};
-          for (let groupPair of useGroups) {
-            let [groupValue] = groupPair;
-
-            // skip null values, they are handled by the none special-case
-            if (groupValue == null)
-              continue;
-
-            let groupStr = nounDef.userVisibleString(groupValue, false);
-            // We use hasOwnProperty because it is possible that groupStr could
-            //  be the same as the name of one of the attributes on
-            //  Object.prototype.
-            if (ambiguousKeyValues.hasOwnProperty(groupStr))
-              ambiguousKeyValues[groupStr] = true;
-            else
-              ambiguousKeyValues[groupStr] = false;
-          }
-        }
-
-        // -- create the items, assigning them to the right list based on
-        //  existing constraint values
-        for (let groupPair of useGroups) {
-          let [groupValue, groupItems] = groupPair;
-          let li = document.createElement("li");
-          li.setAttribute("class", "bar");
-          li.setAttribute("tabindex", "0");
-          li.setAttribute("role", "link");
-          li.setAttribute("aria-haspopup", "true");
-          li.groupValue = groupValue;
-          li.setAttribute("groupValue", groupValue);
-          li.groupItems = groupItems;
-
-          let countSpan = document.createElement("span");
-          countSpan.setAttribute("class", "bar-count");
-          countSpan.textContent = groupItems.length.toLocaleString();
-          li.appendChild(countSpan);
-
-          let label = document.createElement("span");
-          label.setAttribute("class", "bar-link");
-
-          // Set a tooltip with this label's textContent on the binding when
-          // the mouse enters the label. This lets us show the full contents of
-          // the label even if we overflowed. We set it on the binding because
-          // anonymous XBL nodes can't be the source of HTML tooltips. Shadow
-          // DOMs are screwy! (We also clear out the tooltip when the mouse
-          // leaves the label, just to be polite.)
-          let rootBinding = this;
-          label.addEventListener("mouseenter", function() {
-            rootBinding.setAttribute("title", this.textContent);
-          });
-          label.addEventListener("mouseleave", function() {
-            rootBinding.setAttribute("title", "");
-          });
-
-          // The null value is a special indicator for 'none'
-          if (groupValue == null) {
-            label.textContent =
-              glodaFacetStrings.get("glodaFacetView.facets.noneLabel");
-          } else {
-            // Otherwise stringify the group object
-            let labelStr;
-            if (ambiguousKeyValues) {
-              labelStr = nounDef.userVisibleString(groupValue, false);
-              if (ambiguousKeyValues[labelStr])
-                labelStr = nounDef.userVisibleString(groupValue, true);
-            } else if ("labelFunc" in this.facetDef) {
-              labelStr = this.facetDef.labelFunc(groupValue);
-            } else {
-              labelStr = groupValue.toLocaleString().substring(0, 80);
-            }
-            label.textContent = labelStr;
-          }
-          li.appendChild(label);
-
-          // root it under the appropriate list
-          if (constraint) {
-            if (constraint.isIncludedGroup(groupValue)) {
-              li.setAttribute("variety", "include");
-              includeList.appendChild(li);
-            } else if (constraint.isExcludedGroup(groupValue)) {
-              li.setAttribute("variety", "exclude");
-              excludeList.appendChild(li);
-            } else {
-              li.setAttribute("variety", "remainder");
-              remainderList.appendChild(li);
-            }
-          } else {
-            li.setAttribute("variety", "remainder");
-            remainderList.appendChild(li);
-          }
-        }
-
-        this.updateHeaderStates();
-      ]]></body>
-    </method>
-    <!--
-      - Mark the include/exclude headers as "some" if there is anything in their
-      -  lists, mark the remainder header as "needed" if either of include /
-      -  exclude exist so we need that label.
-      -->
-    <method name="updateHeaderStates">
-      <parameter name="aItems" />
-      <body><![CDATA[
-        this.includeLabel.setAttribute("state",
-          this.includeList.childElementCount ? "some" : "empty");
-        this.excludeLabel.setAttribute("state",
-          this.excludeList.childElementCount ? "some" : "empty");
-        this.remainderLabel.setAttribute("needed",
-          ((this.includeList.childElementCount ||
-            this.excludeList.childElementCount) &&
-           this.remainderList.childElementCount) ? "true" : "false");
-
-        // nuke the style attributes.
-        this.includeLabel.removeAttribute("style");
-        this.excludeLabel.removeAttribute("style");
-        this.remainderLabel.removeAttribute("style");
-      ]]></body>
-    </method>
-    <method name="brushItems">
-      <parameter name="aItems" />
-      <body><![CDATA[
-      ]]></body>
-    </method>
-    <method name="clearBrushedItems">
-      <body><![CDATA[
-      ]]></body>
-    </method>
-    <method name="afterListVisible">
-      <parameter name="aVariety" />
-      <parameter name="aCallback" />
-      <body><![CDATA[
-        let labelNode = this[aVariety + "Label"];
-        let listNode = this[aVariety + "List"];
-
-        // if there are already things displayed, no need
-        if (listNode.childElementCount) {
-          aCallback();
-          return;
-        }
-
-        let remListVisible =
-          this.remainderLabel.getAttribute("needed") == "true";
-        let remListShouldBeVisible =
-          this.remainderList.childElementCount > 1;
-
-        labelNode.setAttribute("state", "some");
-
-        // We used to use jQuery here, but it turns out that it has issues with
-        // XML namespaces and stringification, so we now resort to poking the
-        // nodes directly.
-        let showNodes = [labelNode];
-        if (remListVisible != remListShouldBeVisible)
-          showNodes = [labelNode, this.remainderLabel];
-
-        showNodes.forEach(node => node.style.display = "block");
-
-        aCallback();
-      ]]></body>
-    </method>
-    <method name="_flyBarAway">
-      <parameter name="aBarNode" />
-      <parameter name="aVariety" />
-      <parameter name="aCallback" />
-      <body><![CDATA[
-        function getRect(aElement) {
-          let box = aElement.getBoundingClientRect();
-          let documentElement = aElement.ownerDocument.documentElement;
-          return {
-            top: box.top + window.pageYOffset - documentElement.clientTop,
-            left: box.left + window.pageXOffset - documentElement.clientLeft,
-            width: box.width,
-            height: box.height,
-          };
-        }
-        // figure out our origin location prior to adding the target or it
-        //  will shift us down.
-        let origin = getRect(aBarNode);
-
-        // clone the node into its target location
-        let targetNode = aBarNode.cloneNode(true);
-        targetNode.groupValue = aBarNode.groupValue;
-        targetNode.groupItems = aBarNode.groupItems;
-        targetNode.setAttribute("variety", aVariety);
-
-        let targetParent = this[aVariety + "List"];
-        targetParent.appendChild(targetNode);
-
-        // create a flying clone
-        let flyingNode = aBarNode.cloneNode(true);
-
-        let dest = getRect(targetNode);
-
-        // if the flying box wants to go higher than the content box goes, just
-        //  send it to the top of the content box instead.
-        let contentRect = getRect(this.contentBox);
-        if (dest.top < contentRect.top)
-          dest.top = contentRect.top;
-        // likewise if it wants to go further south than the content box, stop
-        //  that
-        if (dest.top > (contentRect.top + contentRect.height))
-          dest.top = contentRect.top + contentRect.height - dest.height;
-
-        flyingNode.style.position = "absolute";
-        flyingNode.style.width = origin.width + "px";
-        flyingNode.style.height = origin.height + "px";
-        flyingNode.style.top = origin.top + "px";
-        flyingNode.style.left = origin.left + "px";
-        flyingNode.style.zIndex = 1000;
-
-        flyingNode.style.transitionDuration = (Math.abs(dest.top - origin.top) * 2) + "ms";
-        flyingNode.style.transitionProperty = "top, left";
-
-        flyingNode.addEventListener("transitionend", function() {
-          aBarNode.remove();
-          targetNode.style.display = "block";
-          flyingNode.remove();
-
-          if (aCallback)
-            setTimeout(aCallback, 50);
-        });
-
-        document.body.appendChild(flyingNode);
-
-        // animate the flying clone... flying!
-        window.requestAnimationFrame(function() {
-          flyingNode.style.top = dest.top + "px";
-          flyingNode.style.left = dest.left + "px";
-        });
-
-        // hide the target (cloned) node
-        targetNode.style.display = "none";
-
-        // hide the original node and remove its JS properties
-        aBarNode.style.visibility = "hidden";
-        delete aBarNode.groupValue;
-        delete aBarNode.groupItems;
-      ]]></body>
-    </method>
-    <method name="barClicked">
-      <parameter name="aBarNode" />
-      <parameter name="aVariety" />
-      <body><![CDATA[
-        let groupValue = aBarNode.groupValue;
-        let dis = this;
-        // These determine what goAnimate actually does.
-        // flyAway allows us to cancel flying in the case the constraint is
-        //  being fully dropped and so the facet is just going to get rebuilt
-        let flyAway = true;
-
-        function goAnimate() {
-          setTimeout(function() {
-            if (flyAway) {
-              dis.afterListVisible(aVariety, function() {
-                dis._flyBarAway(aBarNode, aVariety, function() {
-                  dis.updateHeaderStates();
-                });
-              });
-            }
-          }, 0);
-        }
-
-        // Immediately apply the facet change, triggering the animation after
-        //  the faceting completes.
-        if (aVariety == "remainder") {
-          let currentVariety = aBarNode.getAttribute("variety");
-          let constraintGone = FacetContext.removeFacetConstraint(
-                                 this.faceter,
-                                 currentVariety == "include",
-                                 [groupValue],
-                                 goAnimate);
-          // we will automatically rebuild if the constraint is gone, so
-          //  just make the animation a no-op.
-          if (constraintGone)
-            flyAway = false;
-        } else { // include/exclude
-          let revalidate = FacetContext.addFacetConstraint(
-                             this.faceter,
-                             aVariety == "include",
-                             [groupValue],
-                             false, false, goAnimate);
-          // revalidate means we need to blow away the other dudes, in which
-          //  case it makes the most sense to just trigger a rebuild of ourself
-          if (revalidate) {
-            flyAway = false;
-            this.build(false);
-          }
-        }
-      ]]></body>
-    </method>
-    <method name="barHovered">
-      <parameter name="aBarNode" />
-      <parameter name="aInclude" />
-      <body><![CDATA[
-        let groupValue = aBarNode.groupValue;
-        let groupItems = aBarNode.groupItems;
-
-        FacetContext.hoverFacet(this.faceter, this.attrDef, groupValue, groupItems);
-      ]]></body>
-    </method>
-    <!-- HoverGone! HoverGone!
-         We know it's gone, but where has it gone? -->
-    <method name="barHoverGone">
-      <parameter name="aBarNode" />
-      <parameter name="aInclude" />
-      <body><![CDATA[
-        let groupValue = aBarNode.groupValue;
-        let groupItems = aBarNode.groupItems;
-
-        FacetContext.unhoverFacet(this.faceter, this.attrDef, groupValue, groupItems);
-      ]]></body>
-    </method>
-    <method name="includeFacet">
-      <parameter name="node"/>
-      <body>
-      <![CDATA[
-          this.barClicked(node,
-                          (node.getAttribute("variety") == "remainder") ?
-                            "include" : "remainder");
-      ]]>
-      </body>
-    </method>
-    <method name="undoFacet">
-      <parameter name="node"/>
-      <body>
-      <![CDATA[
-          this.barClicked(node,
-                          (node.getAttribute("variety") == "remainder") ?
-                            "include" : "remainder");
-      ]]>
-      </body>
-    </method>
-    <method name="excludeFacet">
-      <parameter name="node"/>
-      <body>
-      <![CDATA[
-        this.barClicked(node, "exclude");
-      ]]>
-      </body>
-    </method>
-    <method name="showPopup">
-      <parameter name="event"/>
-      <body>
-        <![CDATA[
-        try {
-          // event.originalTarget could be the <li> node, or a span inside
-          // of it, or perhaps the facet-more button, or maybe something
-          // else that we'll handle in the next version.  We walk up its
-          // parent chain until we get to the right level of the DOM
-          // hierarchy, or the facet-content which seems to be the root.
-          if (this.currentNode)
-            this.currentNode.removeAttribute("selected");
-
-          let node = event.originalTarget;
-          while ((!(node && node.hasAttribute && node.hasAttribute("class"))) ||
-                 (!node.classList.contains("bar") &&
-                  !node.classList.contains("facet-more") &&
-                  !node.classList.contains("facet-content")))
-            node = node.parentNode;
-
-          if (!(node && node.hasAttribute && node.hasAttribute("class")))
-            return false;
-
-          this.currentNode = node;
-          node.setAttribute("selected", "true");
-          if (node.classList.contains("bar"))
-            document.getElementById("popup-menu").show(event, this, node);
-          else if (node.classList.contains("facet-more"))
-            this.changeMode("all");
-          return false;
-        } catch (e) {
-          logException(e);
-        }
-      ]]>
-      </body>
-    </method>
-    <method name="activateLink">
-      <parameter name="event"/>
-      <body>
-        <![CDATA[
-        try {
-          let node = event.originalTarget;
-          while ((!node.hasAttribute("class")) ||
-                 (!node.classList.contains("facet-more") &&
-                  !node.classList.contains("facet-content")))
-            node = node.parentNode;
-          if (node.classList.contains("facet-more"))
-            this.changeMode("all");
-          return false;
-        } catch (e) {
-          logException(e);
-        }
-      ]]>
-      </body>
-    </method>
-  </implementation>
-  <handlers>
-    <handler event="click" phase="target" action="this.showPopup(event)"/>
-    <handler event="keypress" keycode="VK_RETURN"
-             action="this.showPopup(event);"
-             phase="target" preventdefault="true"/>
-    <handler event="keypress" key=" "
-             action="this.activateLink(event);"
-             phase="target" preventdefault="true"/>
-    <handler event="mouseover"><![CDATA[
-      // we dispatch based on the class of the thing we clicked on.
-      // there are other ways we could accomplish this, but they all sorta suck.
-      if (event.originalTarget.hasAttribute("class") &&
-          event.originalTarget.classList.contains("bar-link")) {
-        this.barHovered(event.originalTarget.parentNode, true);
-      }
-    ]]></handler>
-    <handler event="mouseout"><![CDATA[
-      // we dispatch based on the class of the thing we clicked on.
-      // there are other ways we could accomplish this, but they all sorta suck.
-      if (event.originalTarget.hasAttribute("class") &&
-          event.originalTarget.classList.contains("bar-link")) {
-        this.barHoverGone(event.originalTarget.parentNode, true);
-      }
-    ]]></handler>
-  </handlers>
-</binding>
-
 <!-- ===== Results ===== -->
 
 <binding id="results-message">
   <content>
     <html:div class="results-message-header">
       <html:h2 class="results-message-count" anonid="count"></html:h2>
       <html:div class="results-message-showall">
         <html:span class="results-message-showall-button"
--- a/mail/base/content/glodaFacetView.js
+++ b/mail/base/content/glodaFacetView.js
@@ -124,16 +124,18 @@ const UIFacets = {
   },
   addFacet(type, attrDef, args) {
     let facet;
 
     if (type === "boolean") {
       facet = document.createElement("facet-boolean");
     } else if (type === "boolean-filtered") {
       facet = document.createElement("facet-boolean-filtered");
+    } else if (type === "discrete") {
+      facet = document.createElement("facet-discrete");
     } else {
       facet = document.createElement("div");
       facet.setAttribute("class", "facetious");
       facet.setAttribute("type", type);
     }
 
     facet.attrDef = attrDef;
     facet.nounDef = attrDef.objectNounDef;