Bug 1071633 - Add dropdown menu to contact buttons. r=mikedeboer
authorPaolo Amadini <paolo.mozmail@amadzone.org>
Fri, 26 Sep 2014 13:53:54 -0700
changeset 218116 e4bc85cfd6e7467bcf764fd4c5d9f20e45d94de4
parent 218115 3e8c6fa7273415ba29ebb533ffa939006e64d4b3
child 218117 05c0e2b99f47f05ce78ad1056d77127da80db8b7
push id2
push usergszorc@mozilla.com
push dateWed, 12 Nov 2014 19:43:22 +0000
treeherderfig@7a5f4d72e05d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmikedeboer
bugs1071633
milestone34.0a2
Bug 1071633 - Add dropdown menu to contact buttons. r=mikedeboer
browser/components/loop/content/js/contacts.js
browser/components/loop/content/js/contacts.jsx
browser/components/loop/content/shared/css/contacts.css
--- a/browser/components/loop/content/js/contacts.js
+++ b/browser/components/loop/content/js/contacts.js
@@ -12,25 +12,123 @@ loop.contacts = (function(_, mozL10n) {
   "use strict";
 
   const Button = loop.shared.views.Button;
   const ButtonGroup = loop.shared.views.ButtonGroup;
 
   // Number of contacts to add to the list at the same time.
   const CONTACTS_CHUNK_SIZE = 100;
 
-  const ContactDetail = React.createClass({displayName: 'ContactDetail',
+  const ContactDropdown = React.createClass({displayName: 'ContactDropdown',
     propTypes: {
-      handleContactClick: React.PropTypes.func,
+      handleAction: React.PropTypes.func.isRequired,
+    },
+
+    getInitialState: function () {
+      return {
+        openDirUp: false,
+      };
+    },
+
+    componentDidMount: function () {
+      // This method is called once when the dropdown menu is added to the DOM
+      // inside the contact item.  If the menu extends outside of the visible
+      // area of the scrollable list, it is re-rendered in different direction.
+
+      let menuNode = this.getDOMNode();
+      let menuNodeRect = menuNode.getBoundingClientRect();
+
+      let listNode = document.getElementsByClassName("contact-list")[0];
+      let listNodeRect = listNode.getBoundingClientRect();
+
+      if (menuNodeRect.top + menuNodeRect.height >=
+          listNodeRect.top + listNodeRect.height) {
+        this.setState({
+          openDirUp: true,
+        });
+      }
+    },
+
+    onItemClick: function(event) {
+      this.props.handleAction(event.currentTarget.dataset.action);
+    },
+
+    render: function() {
+      let dropdownMenu = React.addons.classSet({
+        "dropdown-menu": true,
+        "dropdown-menu-up": this.state.openDirUp,
+      });
+
+      return (
+        React.DOM.ul({className: dropdownMenu}, 
+          React.DOM.li({className: "dropdown-menu-item disabled", 
+              onClick: this.onItemClick, 'data-action': "video-call"}, 
+            React.DOM.i({className: "icon icon-video-call"}), 
+            mozL10n.get("video_call_menu_button")
+          ), 
+          React.DOM.li({className: "dropdown-menu-item disabled", 
+              onClick: this.onItemClick, 'data-action': "audio-call"}, 
+            React.DOM.i({className: "icon icon-audio-call"}), 
+            mozL10n.get("audio_call_menu_button")
+          ), 
+          React.DOM.li({className: "dropdown-menu-item", 
+              onClick: this.onItemClick, 'data-action': "edit"}, 
+            React.DOM.i({className: "icon icon-edit"}), 
+            mozL10n.get("edit_contact_menu_button")
+          ), 
+          React.DOM.li({className: "dropdown-menu-item disabled", 
+              onClick: this.onItemClick, 'data-action': "block"}, 
+            React.DOM.i({className: "icon icon-block"}), 
+            mozL10n.get("block_contact_menu_button")
+          ), 
+          React.DOM.li({className: "dropdown-menu-item disabled", 
+              onClick: this.onItemClick, 'data-action': "delete"}, 
+            React.DOM.i({className: "icon icon-delete"}), 
+            mozL10n.get("remove_contact_menu_button")
+          )
+        )
+      );
+    }
+  });
+
+  const ContactDetail = React.createClass({displayName: 'ContactDetail',
+    getInitialState: function() {
+      return {
+        showMenu: false,
+      };
+    },
+
+    propTypes: {
+      handleContactAction: React.PropTypes.func,
       contact: React.PropTypes.object.isRequired
     },
 
-    handleContactClick: function() {
-      if (this.props.handleContactClick) {
-        this.props.handleContactClick(this.props.key);
+    _onBodyClick: function() {
+      // Hide the menu after other click handlers have been invoked.
+      setTimeout(this.hideDropdownMenu, 10);
+    },
+
+    showDropdownMenu: function() {
+      document.body.addEventListener("click", this._onBodyClick);
+      this.setState({showMenu: true});
+    },
+
+    hideDropdownMenu: function() {
+      document.body.removeEventListener("click", this._onBodyClick);
+      this.setState({showMenu: false});
+    },
+
+    componentWillUnmount: function() {
+      document.body.removeEventListener("click", this._onBodyClick);
+    },
+
+    handleAction: function(actionName) {
+      console.error("Actions not implemented: " + actionName);
+      if (this.props.handleContactAction) {
+        this.props.handleContactAction(this.props.key, actionName);
       }
     },
 
     getContactNames: function() {
       // The model currently does not enforce a name to be present, but we're
       // going to assume it is awaiting more advanced validation of required fields
       // by the model. (See bug 1069918)
       // NOTE: this method of finding a firstname and lastname is not i18n-proof.
@@ -61,31 +159,37 @@ loop.contacts = (function(_, mozL10n) {
       let email = this.getPreferredEmail();
       let cx = React.addons.classSet;
       let contactCSSClass = cx({
         contact: true,
         blocked: this.props.contact.blocked
       });
 
       return (
-        React.DOM.li({onClick: this.handleContactClick, className: contactCSSClass}, 
+        React.DOM.li({className: contactCSSClass, onMouseLeave: this.hideDropdownMenu}, 
           React.DOM.div({className: "avatar"}, 
             React.DOM.img({src: navigator.mozLoop.getUserAvatar(email.value)})
           ), 
           React.DOM.div({className: "details"}, 
             React.DOM.div({className: "username"}, React.DOM.strong(null, names.firstName), " ", names.lastName, 
               React.DOM.i({className: cx({"icon icon-google": this.props.contact.category[0] == "google"})}), 
               React.DOM.i({className: cx({"icon icon-blocked": this.props.contact.blocked})})
             ), 
             React.DOM.div({className: "email"}, email.value)
           ), 
           React.DOM.div({className: "icons"}, 
-            React.DOM.i({className: "icon icon-video"}), 
-            React.DOM.i({className: "icon icon-caret-down"})
-          )
+            React.DOM.i({className: "icon icon-video", 
+               onClick: this.handleAction.bind(null, "video-call")}), 
+            React.DOM.i({className: "icon icon-caret-down", 
+               onClick: this.showDropdownMenu})
+          ), 
+          this.state.showMenu
+            ? ContactDropdown({handleAction: this.handleAction})
+            : null
+          
         )
       );
     }
   });
 
   const ContactsList = React.createClass({displayName: 'ContactsList',
     getInitialState: function() {
       return {
--- a/browser/components/loop/content/js/contacts.jsx
+++ b/browser/components/loop/content/js/contacts.jsx
@@ -12,25 +12,123 @@ loop.contacts = (function(_, mozL10n) {
   "use strict";
 
   const Button = loop.shared.views.Button;
   const ButtonGroup = loop.shared.views.ButtonGroup;
 
   // Number of contacts to add to the list at the same time.
   const CONTACTS_CHUNK_SIZE = 100;
 
-  const ContactDetail = React.createClass({
+  const ContactDropdown = React.createClass({
     propTypes: {
-      handleContactClick: React.PropTypes.func,
+      handleAction: React.PropTypes.func.isRequired,
+    },
+
+    getInitialState: function () {
+      return {
+        openDirUp: false,
+      };
+    },
+
+    componentDidMount: function () {
+      // This method is called once when the dropdown menu is added to the DOM
+      // inside the contact item.  If the menu extends outside of the visible
+      // area of the scrollable list, it is re-rendered in different direction.
+
+      let menuNode = this.getDOMNode();
+      let menuNodeRect = menuNode.getBoundingClientRect();
+
+      let listNode = document.getElementsByClassName("contact-list")[0];
+      let listNodeRect = listNode.getBoundingClientRect();
+
+      if (menuNodeRect.top + menuNodeRect.height >=
+          listNodeRect.top + listNodeRect.height) {
+        this.setState({
+          openDirUp: true,
+        });
+      }
+    },
+
+    onItemClick: function(event) {
+      this.props.handleAction(event.currentTarget.dataset.action);
+    },
+
+    render: function() {
+      let dropdownMenu = React.addons.classSet({
+        "dropdown-menu": true,
+        "dropdown-menu-up": this.state.openDirUp,
+      });
+
+      return (
+        <ul className={dropdownMenu}>
+          <li className="dropdown-menu-item disabled"
+              onClick={this.onItemClick} data-action="video-call">
+            <i className="icon icon-video-call" />
+            {mozL10n.get("video_call_menu_button")}
+          </li>
+          <li className="dropdown-menu-item disabled"
+              onClick={this.onItemClick} data-action="audio-call">
+            <i className="icon icon-audio-call" />
+            {mozL10n.get("audio_call_menu_button")}
+          </li>
+          <li className="dropdown-menu-item"
+              onClick={this.onItemClick} data-action="edit">
+            <i className="icon icon-edit" />
+            {mozL10n.get("edit_contact_menu_button")}
+          </li>
+          <li className="dropdown-menu-item disabled"
+              onClick={this.onItemClick} data-action="block">
+            <i className="icon icon-block" />
+            {mozL10n.get("block_contact_menu_button")}
+          </li>
+          <li className="dropdown-menu-item disabled"
+              onClick={this.onItemClick} data-action="delete">
+            <i className="icon icon-delete" />
+            {mozL10n.get("remove_contact_menu_button")}
+          </li>
+        </ul>
+      );
+    }
+  });
+
+  const ContactDetail = React.createClass({
+    getInitialState: function() {
+      return {
+        showMenu: false,
+      };
+    },
+
+    propTypes: {
+      handleContactAction: React.PropTypes.func,
       contact: React.PropTypes.object.isRequired
     },
 
-    handleContactClick: function() {
-      if (this.props.handleContactClick) {
-        this.props.handleContactClick(this.props.key);
+    _onBodyClick: function() {
+      // Hide the menu after other click handlers have been invoked.
+      setTimeout(this.hideDropdownMenu, 10);
+    },
+
+    showDropdownMenu: function() {
+      document.body.addEventListener("click", this._onBodyClick);
+      this.setState({showMenu: true});
+    },
+
+    hideDropdownMenu: function() {
+      document.body.removeEventListener("click", this._onBodyClick);
+      this.setState({showMenu: false});
+    },
+
+    componentWillUnmount: function() {
+      document.body.removeEventListener("click", this._onBodyClick);
+    },
+
+    handleAction: function(actionName) {
+      console.error("Actions not implemented: " + actionName);
+      if (this.props.handleContactAction) {
+        this.props.handleContactAction(this.props.key, actionName);
       }
     },
 
     getContactNames: function() {
       // The model currently does not enforce a name to be present, but we're
       // going to assume it is awaiting more advanced validation of required fields
       // by the model. (See bug 1069918)
       // NOTE: this method of finding a firstname and lastname is not i18n-proof.
@@ -61,31 +159,37 @@ loop.contacts = (function(_, mozL10n) {
       let email = this.getPreferredEmail();
       let cx = React.addons.classSet;
       let contactCSSClass = cx({
         contact: true,
         blocked: this.props.contact.blocked
       });
 
       return (
-        <li onClick={this.handleContactClick} className={contactCSSClass}>
+        <li className={contactCSSClass} onMouseLeave={this.hideDropdownMenu}>
           <div className="avatar">
             <img src={navigator.mozLoop.getUserAvatar(email.value)} />
           </div>
           <div className="details">
             <div className="username"><strong>{names.firstName}</strong> {names.lastName}
               <i className={cx({"icon icon-google": this.props.contact.category[0] == "google"})} />
               <i className={cx({"icon icon-blocked": this.props.contact.blocked})} />
             </div>
             <div className="email">{email.value}</div>
           </div>
           <div className="icons">
-            <i className="icon icon-video" />
-            <i className="icon icon-caret-down" />
+            <i className="icon icon-video"
+               onClick={this.handleAction.bind(null, "video-call")} />
+            <i className="icon icon-caret-down"
+               onClick={this.showDropdownMenu} />
           </div>
+          {this.state.showMenu
+            ? <ContactDropdown handleAction={this.handleAction} />
+            : null
+          }
         </li>
       );
     }
   });
 
   const ContactsList = React.createClass({
     getInitialState: function() {
       return {
--- a/browser/components/loop/content/shared/css/contacts.css
+++ b/browser/components/loop/content/shared/css/contacts.css
@@ -1,18 +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/. */
 
 .contact-list {
   border-top: 1px solid #ccc;
   overflow-x: hidden;
   overflow-y: auto;
-  /* Show six contacts and scroll for the rest */
-  max-height: 305px;
+  /* We need enough space to show the context menu of the first contact. */
+  min-height: 204px;
+  /* Show six contacts and scroll for the rest. */
+  max-height: 306px;
 }
 
 .contact,
 .contact-separator {
   padding: 5px 10px;
   font-size: 13px;
 }
 
@@ -39,17 +41,17 @@
 }
 
 .contact:hover {
   background: #eee;
 }
 
 .contact:hover > .icons {
   display: block;
-  z-index: 1000;
+  z-index: 1;
 }
 
 .contact > .avatar {
   width: 40px;
   height: 40px;
   background: #ccc;
   border-radius: 50%;
   margin-right: 10px;
@@ -141,11 +143,53 @@
 
 .icons i.icon-caret-down {
   background-image: url("../img/icons-10x10.svg#dropdown-white");
   background-size: 10px 10px;
   width: 10px;
   height: 16px;
 }
 
+.contact > .dropdown-menu {
+  z-index: 2;
+  top: 10px;
+  bottom: auto;
+  right: 3em;
+  left: auto;
+}
+
+.contact > .dropdown-menu-up {
+  bottom: 10px;
+  top: auto;
+}
+
+.contact > .dropdown-menu > .dropdown-menu-item > .icon {
+  display: inline-block;
+  width: 20px;
+  height: 10px;
+  background-position: center left;
+  background-size: 10px 10px;
+  background-repeat: no-repeat;
+}
+
+.contact > .dropdown-menu > .dropdown-menu-item > .icon-audio-call {
+  background-image: url("../img/icons-16x16.svg#audio");
+}
+
+.contact > .dropdown-menu > .dropdown-menu-item > .icon-video-call {
+  background-image: url("../img/icons-16x16.svg#video");
+}
+
+.contact > .dropdown-menu > .dropdown-menu-item > .icon-edit {
+  background-image: url("../img/icons-16x16.svg#contacts");
+}
+
+.contact > .dropdown-menu > .dropdown-menu-item > .icon-block {
+  background-image: url("../img/icons-16x16.svg#block");
+}
+
+.contact > .dropdown-menu > .dropdown-menu-item > .icon-delete {
+  background-image: url("../img/icons-16x16.svg#delete");
+}
+
 .contact-form > .button-group {
   margin-top: 14px;
 }