Bug 1142515: add a generic custom checkbox widget for Loop UI elements to use. r=Standard8
authorMike de Boer <mdeboer@mozilla.com>
Thu, 07 May 2015 11:38:51 +0200
changeset 242660 8fe39788519c4aa9a9b93797a4b270001e48d333
parent 242659 75784d87edf5a57bd5dc8ecb921b87d91055f6f0
child 242661 991020c5923b65230677917124cd827ead25cb68
push id12781
push usermdeboer@mozilla.com
push dateThu, 07 May 2015 09:42:46 +0000
treeherderfx-team@855bbeb7d41f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersStandard8
bugs1142515
milestone40.0a1
Bug 1142515: add a generic custom checkbox widget for Loop UI elements to use. r=Standard8
browser/components/loop/content/shared/css/common.css
browser/components/loop/content/shared/img/check.svg
browser/components/loop/content/shared/js/views.js
browser/components/loop/content/shared/js/views.jsx
browser/components/loop/jar.mn
browser/components/loop/test/shared/views_test.js
--- a/browser/components/loop/content/shared/css/common.css
+++ b/browser/components/loop/content/shared/css/common.css
@@ -453,8 +453,51 @@ body[dir=rtl] .dropdown-menu-item {
 }
 
 .dropdown-menu-separator {
   height: 1px;
   margin: 2px -2px 1px -2px;
   border-top: 1px solid #dedede;
   background-color: #fff;
 }
+
+/* Custom checkbox */
+
+.checkbox-wrapper {
+  -moz-user-select: none;
+  user-select: none;
+}
+
+.checkbox {
+  float: left;
+  width: 1em;
+  height: 1em;
+  -moz-margin-end: .5em;
+  margin-top: .1em;
+  border: 1px solid #999;
+  border-radius: 3px;
+  cursor: pointer;
+  background-color: transparent;
+  background-position: center center;
+  background-repeat: no-repeat;
+  background-size: 1em 1em;
+}
+
+body[dir="rtl"] .checkbox {
+  float: right;
+}
+
+.checkbox.checked {
+  background-image: url("../img/check.svg#check");
+}
+
+.checkbox.checked:hover,
+.checkbox.checked:hover:active {
+  background-image: url("../img/check.svg#check-active");
+}
+
+.checkbox.disabled {
+  border: 1px solid #909090;
+}
+
+.checkbox.checked.disabled {
+  background-image: url("../img/check.svg#check-disabled");
+}
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/img/check.svg
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg"
+     xmlns:xlink="http://www.w3.org/1999/xlink"
+     x="0" y="0"
+     width="21" height="21"
+     viewBox="0 0 21 21">
+  <style>
+    use:not(:target) {
+      display: none;
+    }
+    use {
+      fill: #fff;
+      stroke: #fff;
+      stroke-width: 0.5;
+    }
+    use[id$="-inverted"] {
+      fill: #0095dd;
+      stroke: #0095dd;
+      stroke-width: 0.5;
+    }
+    use[id$="-disabled"] {
+      fill: rgba(255,255,255,.4);
+      stroke: rgba(255,255,255,.4);
+      stroke-width: 0.5;
+    }
+  </style>
+  <defs style="display: none;">
+    <path id="check-shape" d="M 9.39,16.5 16.28,6 14.77,4.5 9.37,12.7 6.28,9.2 4.7,10.7 z"/>
+  </defs>
+  <use id="check"          xlink:href="#check-shape"/>
+  <use id="check-active"   xlink:href="#check-shape"/>
+  <use id="check-disabled" xlink:href="#check-shape"/>
+</svg>
--- a/browser/components/loop/content/shared/js/views.js
+++ b/browser/components/loop/content/shared/js/views.js
@@ -609,18 +609,86 @@ loop.shared.views = (function(_, l10n) {
       return (
         React.createElement("div", {className: cx(classObject)}, 
           this.props.children
         )
       );
     }
   });
 
+  var Checkbox = React.createClass({displayName: "Checkbox",
+    PropTypes: {
+      additionalClass: React.PropTypes.string,
+      checked: React.PropTypes.bool,
+      disabled: React.PropTypes.bool,
+      label: React.PropTypes.string,
+      onChange: React.PropTypes.func.isRequired,
+      // If `value` is not supplied, the consumer should rely on the boolean
+      // `checked` state changes.
+      value: React.PropTypes.string
+    },
+
+    getDefaultProps: function() {
+      return {
+        additionalClass: "",
+        checked: false,
+        disabled: false,
+        label: null,
+        value: ""
+      };
+    },
+
+    getInitialState: function() {
+      return {
+        checked: this.props.checked,
+        value: this.props.checked ? this.props.value : ""
+      };
+    },
+
+    _handleClick: function(event) {
+      event.preventDefault();
+
+      var newState = {
+        checked: !this.state.checked,
+        value: this.state.checked ? "" : this.props.value
+      };
+      this.setState(newState);
+      this.props.onChange(newState);
+    },
+
+    render: function() {
+      var cx = React.addons.classSet;
+      var wrapperClasses = {
+        "checkbox-wrapper": true,
+        disabled: this.props.disabled
+      };
+      var checkClasses = {
+        checkbox: true,
+        checked: this.state.checked,
+        disabled: this.props.disabled
+      };
+      if (this.props.additionalClass) {
+        checkClasses[this.props.additionalClass] = true;
+      }
+      return (
+        React.createElement("div", {className: cx(wrapperClasses), 
+             disabled: this.props.disabled, 
+             onClick: this._handleClick}, 
+          React.createElement("div", {className: cx(checkClasses)}), 
+          this.props.label ?
+            React.createElement("label", null, this.props.label) :
+            null
+        )
+      );
+    }
+  });
+
   return {
     Button: Button,
     ButtonGroup: ButtonGroup,
+    Checkbox: Checkbox,
     ConversationView: ConversationView,
     ConversationToolbar: ConversationToolbar,
     MediaControlButton: MediaControlButton,
     ScreenShareControlButton: ScreenShareControlButton,
     NotificationListView: NotificationListView
   };
 })(_, navigator.mozL10n || document.mozL10n);
--- a/browser/components/loop/content/shared/js/views.jsx
+++ b/browser/components/loop/content/shared/js/views.jsx
@@ -609,18 +609,86 @@ loop.shared.views = (function(_, l10n) {
       return (
         <div className={cx(classObject)}>
           {this.props.children}
         </div>
       );
     }
   });
 
+  var Checkbox = React.createClass({
+    PropTypes: {
+      additionalClass: React.PropTypes.string,
+      checked: React.PropTypes.bool,
+      disabled: React.PropTypes.bool,
+      label: React.PropTypes.string,
+      onChange: React.PropTypes.func.isRequired,
+      // If `value` is not supplied, the consumer should rely on the boolean
+      // `checked` state changes.
+      value: React.PropTypes.string
+    },
+
+    getDefaultProps: function() {
+      return {
+        additionalClass: "",
+        checked: false,
+        disabled: false,
+        label: null,
+        value: ""
+      };
+    },
+
+    getInitialState: function() {
+      return {
+        checked: this.props.checked,
+        value: this.props.checked ? this.props.value : ""
+      };
+    },
+
+    _handleClick: function(event) {
+      event.preventDefault();
+
+      var newState = {
+        checked: !this.state.checked,
+        value: this.state.checked ? "" : this.props.value
+      };
+      this.setState(newState);
+      this.props.onChange(newState);
+    },
+
+    render: function() {
+      var cx = React.addons.classSet;
+      var wrapperClasses = {
+        "checkbox-wrapper": true,
+        disabled: this.props.disabled
+      };
+      var checkClasses = {
+        checkbox: true,
+        checked: this.state.checked,
+        disabled: this.props.disabled
+      };
+      if (this.props.additionalClass) {
+        checkClasses[this.props.additionalClass] = true;
+      }
+      return (
+        <div className={cx(wrapperClasses)}
+             disabled={this.props.disabled}
+             onClick={this._handleClick}>
+          <div className={cx(checkClasses)} />
+          {this.props.label ?
+            <label>{this.props.label}</label> :
+            null}
+        </div>
+      );
+    }
+  });
+
   return {
     Button: Button,
     ButtonGroup: ButtonGroup,
+    Checkbox: Checkbox,
     ConversationView: ConversationView,
     ConversationToolbar: ConversationToolbar,
     MediaControlButton: MediaControlButton,
     ScreenShareControlButton: ScreenShareControlButton,
     NotificationListView: NotificationListView
   };
 })(_, navigator.mozL10n || document.mozL10n);
--- a/browser/components/loop/jar.mn
+++ b/browser/components/loop/jar.mn
@@ -51,16 +51,17 @@ browser.jar:
   content/browser/loop/shared/img/dropdown-inverse@2x.png       (content/shared/img/dropdown-inverse@2x.png)
   content/browser/loop/shared/img/svg/glyph-settings-16x16.svg  (content/shared/img/svg/glyph-settings-16x16.svg)
   content/browser/loop/shared/img/svg/glyph-account-16x16.svg   (content/shared/img/svg/glyph-account-16x16.svg)
   content/browser/loop/shared/img/svg/glyph-signin-16x16.svg    (content/shared/img/svg/glyph-signin-16x16.svg)
   content/browser/loop/shared/img/svg/glyph-signout-16x16.svg   (content/shared/img/svg/glyph-signout-16x16.svg)
   content/browser/loop/shared/img/svg/glyph-help-16x16.svg      (content/shared/img/svg/glyph-help-16x16.svg)
   content/browser/loop/shared/img/audio-call-avatar.svg         (content/shared/img/audio-call-avatar.svg)
   content/browser/loop/shared/img/beta-ribbon.svg               (content/shared/img/beta-ribbon.svg)
+  content/browser/loop/shared/img/check.svg                     (content/shared/img/check.svg)
   content/browser/loop/shared/img/icons-10x10.svg               (content/shared/img/icons-10x10.svg)
   content/browser/loop/shared/img/icons-14x14.svg               (content/shared/img/icons-14x14.svg)
   content/browser/loop/shared/img/icons-16x16.svg               (content/shared/img/icons-16x16.svg)
   content/browser/loop/shared/img/movistar.png                  (content/shared/img/movistar.png)
   content/browser/loop/shared/img/movistar@2x.png               (content/shared/img/movistar@2x.png)
   content/browser/loop/shared/img/vivo.png                      (content/shared/img/vivo.png)
   content/browser/loop/shared/img/vivo@2x.png                   (content/shared/img/vivo@2x.png)
   content/browser/loop/shared/img/02.png                        (content/shared/img/02.png)
--- a/browser/components/loop/test/shared/views_test.js
+++ b/browser/components/loop/test/shared/views_test.js
@@ -719,10 +719,101 @@ describe("loop.shared.views", function()
 
       it("should render when the collection is reset", function() {
         coll.reset();
 
         sinon.assert.calledOnce(view.render);
       });
     });
   });
+
+  describe("Checkbox", function() {
+    var view;
+
+    afterEach(function() {
+      view = null;
+    });
+
+    function mountTestComponent(props) {
+      props = _.extend({ onChange: function() {} }, props);
+      return TestUtils.renderIntoDocument(
+        React.createElement(sharedViews.Checkbox, props));
+    }
+
+    describe("#render", function() {
+      it("should render a checkbox with only required props supplied", function() {
+        view = mountTestComponent();
+
+        var node = view.getDOMNode();
+        expect(node).to.not.eql(null);
+        expect(node.classList.contains("checkbox-wrapper")).to.eql(true);
+        expect(node.hasAttribute("disabled")).to.eql(false);
+        expect(node.childNodes.length).to.eql(1);
+      });
+
+      it("should render a label when it's supplied", function() {
+        view = mountTestComponent({ label: "Some label" });
+
+        var node = view.getDOMNode();
+        expect(node.lastChild.localName).to.eql("label");
+        expect(node.lastChild.textContent).to.eql("Some label");
+      });
+
+      it("should render the checkbox as disabled when told to", function() {
+        view = mountTestComponent({
+          disabled: true
+        });
+
+        var node = view.getDOMNode();
+        expect(node.classList.contains("disabled")).to.eql(true);
+        expect(node.hasAttribute("disabled")).to.eql(true);
+      });
+    });
+
+    describe("#_handleClick", function() {
+      var onChange;
+
+      beforeEach(function() {
+        onChange = sinon.stub();
+      });
+
+      afterEach(function() {
+        onChange = null;
+      });
+
+      it("should invoke the `onChange` function on click", function() {
+        view = mountTestComponent({ onChange: onChange });
+
+        expect(view.state.checked).to.eql(false);
+
+        var node = view.getDOMNode();
+        TestUtils.Simulate.click(node);
+
+        expect(view.state.checked).to.eql(true);
+        sinon.assert.calledOnce(onChange);
+        sinon.assert.calledWithExactly(onChange, {
+          checked: true,
+          value: ""
+        });
+      });
+
+      it("should signal a value change on click", function() {
+        view = mountTestComponent({
+          onChange: onChange,
+          value: "some-value"
+        });
+
+        expect(view.state.value).to.eql("");
+
+        var node = view.getDOMNode();
+        TestUtils.Simulate.click(node);
+
+        expect(view.state.value).to.eql("some-value");
+        sinon.assert.calledOnce(onChange);
+        sinon.assert.calledWithExactly(onChange, {
+          checked: true,
+          value: "some-value"
+        });
+      });
+    });
+  });
 });