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 242724 8fe39788519c4aa9a9b93797a4b270001e48d333
parent 242723 75784d87edf5a57bd5dc8ecb921b87d91055f6f0
child 242725 991020c5923b65230677917124cd827ead25cb68
push id28707
push userkwierso@gmail.com
push dateThu, 07 May 2015 21:58:59 +0000
treeherdermozilla-central@5e83fa48971d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersStandard8
bugs1142515
milestone40.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 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"
+        });
+      });
+    });
+  });
 });