Bug 1301284 - Update time picker style to match visual spec; r=mconley draft
authorScott Wu <scottcwwu@gmail.com>
Mon, 17 Oct 2016 17:24:39 +0800
changeset 435235 a2916d31c14d61ae9fed148ac2e30533402b0599
parent 435182 f13e90d496cf1bc6dfc4fd398da33e4afe785bde
child 536248 2ccbab4c6701e3cb4fb66d9e614f7ca031af8818
push id34970
push userbmo:scwwu@mozilla.com
push dateTue, 08 Nov 2016 07:06:03 +0000
reviewersmconley
bugs1301284
milestone52.0a1
Bug 1301284 - Update time picker style to match visual spec; r=mconley MozReview-Commit-ID: LqYl8qRzlBg
toolkit/content/timepicker.xhtml
toolkit/content/widgets/datetimepopup.xml
toolkit/content/widgets/spinner.js
toolkit/content/widgets/timepicker.js
toolkit/themes/shared/timepicker.css
--- a/toolkit/content/timepicker.xhtml
+++ b/toolkit/content/timepicker.xhtml
@@ -10,21 +10,19 @@
   <script type="application/javascript" src="chrome://global/content/bindings/timekeeper.js"></script>
   <script type="application/javascript" src="chrome://global/content/bindings/spinner.js"></script>
   <script type="application/javascript" src="chrome://global/content/bindings/timepicker.js"></script>
 </head>
 <body>
   <div id="time-picker"></div>
   <template id="spinner-template">
     <div class="spinner-container">
-      <button class="up">▲</button>
-      <div class="stack">
-        <div class="spinner"></div>
-      </div>
-      <button class="down">▼</button>
+      <button class="up"/>
+      <div class="spinner"></div>
+      <button class="down"/>
     </div>
   </template>
   <script type="application/javascript">
   // We need to hide the scroll bar but maintain its scrolling
   // capability, so using |overflow: hidden| is not an option.
   // Instead, we are inserting a user agent stylesheet that is
   // capable of selecting scrollbars, and do |display: none|.
   var domWinUtls = window.QueryInterface(Components.interfaces.nsIInterfaceRequestor).
--- a/toolkit/content/widgets/datetimepopup.xml
+++ b/toolkit/content/widgets/datetimepopup.xml
@@ -10,25 +10,27 @@
    xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
    xmlns:xbl="http://www.mozilla.org/xbl">
   <binding id="datetime-popup"
            extends="chrome://global/content/bindings/popup.xml#arrowpanel">
     <implementation>
       <field name="dateTimePopupFrame">
         this.querySelector("#dateTimePopupFrame");
       </field>
-      <field name="TIME_PICKER_WIDTH" readonly="true">"14em"</field>
-      <field name="TIME_PICKER_HEIGHT" readonly="true">"14em"</field>
+      <field name="TIME_PICKER_WIDTH" readonly="true">"12em"</field>
+      <field name="TIME_PICKER_HEIGHT" readonly="true">"21em"</field>
       <method name="loadPicker">
         <parameter name="type"/>
         <parameter name="detail"/>
         <body><![CDATA[
           this.hidden = false;
           this.type = type;
           this.pickerState = {};
+          // TODO: Resize picker according to content zoom level
+          this.style.fontSize = "10px";
           switch (type) {
             case "time": {
               this.detail = detail;
               this.dateTimePopupFrame.addEventListener("load", this, true);
               this.dateTimePopupFrame.setAttribute("src", "chrome://global/content/timepicker.xhtml");
               this.dateTimePopupFrame.style.width = this.TIME_PICKER_WIDTH;
               this.dateTimePopupFrame.style.height = this.TIME_PICKER_HEIGHT;
               break;
--- a/toolkit/content/widgets/spinner.js
+++ b/toolkit/content/widgets/spinner.js
@@ -13,66 +13,75 @@
  */
 
 function Spinner(props, context) {
   this.context = context;
   this._init(props);
 }
 
 {
-  const debug = 0 ? console.log.bind(console, '[spinner]') : function() {};
+  const debug = 0 ? console.log.bind(console, "[spinner]") : function() {};
 
-  const ITEM_HEIGHT = 20,
-        VIEWPORT_SIZE = 5,
-        VIEWPORT_COUNT = 5;
+  const ITEM_HEIGHT = 2.5,
+        VIEWPORT_SIZE = 7,
+        VIEWPORT_COUNT = 5,
+        SCROLL_TIMEOUT = 100;
 
   Spinner.prototype = {
     /**
      * Initializes a spinner. Set the default states and properties, cache
      * element references, create the HTML markup, and add event listeners.
      *
      * @param  {Object} props [Properties passed in from parent]
      *         {
      *           {Function} setValue: Takes a value and set the state to
      *             the parent component.
      *           {Function} getDisplayString: Takes a value, and output it
      *             as localized strings.
-     *           {Number} itemHeight [optional]: Item height in pixels.
      *           {Number} viewportSize [optional]: Number of items in a
      *             viewport.
+     *           {Boolean} hideButtons [optional]: Hide up & down buttons
+     *           {Number} rootFontSize [optional]: Used to support zoom in/out
      *         }
      */
     _init(props) {
-      const { setValue, getDisplayString, itemHeight = ITEM_HEIGHT } = props;
+      const { setValue, getDisplayString, hideButtons, rootFontSize = 10 } = props;
 
       const spinnerTemplate = document.getElementById("spinner-template");
       const spinnerElement = document.importNode(spinnerTemplate.content, true);
 
       // Make sure viewportSize is an odd number because we want to have the selected
       // item in the center. If it's an even number, use the default size instead.
       const viewportSize = props.viewportSize % 2 ? props.viewportSize : VIEWPORT_SIZE;
 
       this.state = {
         items: [],
         isScrolling: false
       };
       this.props = {
-        setValue, getDisplayString, itemHeight, viewportSize,
+        setValue, getDisplayString, viewportSize, rootFontSize,
         // We can assume that the viewportSize is an odd number. Calculate how many
         // items we need to insert on top of the spinner so that the selected is at
         // the center. Ex: if viewportSize is 5, we need 2 items on top.
         viewportTopOffset: (viewportSize - 1) / 2
       };
       this.elements = {
+        container: spinnerElement.querySelector(".spinner-container"),
         spinner: spinnerElement.querySelector(".spinner"),
         up: spinnerElement.querySelector(".up"),
         down: spinnerElement.querySelector(".down"),
         itemsViewElements: []
       };
 
+      this.elements.spinner.style.height = (ITEM_HEIGHT * viewportSize) + "rem";
+
+      if (hideButtons) {
+        this.elements.container.classList.add("hide-buttons");
+      }
+
       this.context.appendChild(spinnerElement);
       this._attachEventListeners();
     },
 
     /**
      * Only the parent component calls setState on the spinner.
      * It checks if the items have changed and updates the spinner.
      * If only the value has changed, smooth scrolls to the new value.
@@ -114,17 +123,17 @@ function Spinner(props, context) {
      * - Update the index state
      * - If a smooth scroll has reached its destination, set [isScrolling] state
      *   to false
      * - If the value has changed, update the [value] state and call [setValue]
      * - If infinite scrolling is on, reset the scrolling position if necessary
      */
     _onScroll() {
       const { items, itemsView, isInfiniteScroll } = this.state;
-      const { viewportSize, viewportTopOffset, itemHeight } = this.props;
+      const { viewportSize, viewportTopOffset } = this.props;
       const { spinner, itemsViewElements } = this.elements;
 
       this.state.index = this._getIndexByOffset(spinner.scrollTop);
 
       const value = itemsView[this.state.index + viewportTopOffset].value;
 
       // Check if smooth scrolling has reached its destination.
       // This prevents input box jump when input box changes values.
@@ -143,16 +152,26 @@ function Spinner(props, context) {
       if (items.length >= viewportSize && isInfiniteScroll) {
         // If the scroll position is near the top or bottom, jump back to the middle
         // so user can keep scrolling up or down.
         if (this.state.index < viewportSize ||
             this.state.index > itemsView.length - viewportSize) {
           this._scrollTo(this.state.value, true);
         }
       }
+
+      // Use a timer to detect if a scroll event has not fired within some time
+      // (defined in SCROLL_TIMEOUT). This is required because we need to hide
+      // highlight and hover state when user is scrolling.
+      clearTimeout(this.state.scrollTimer);
+      this.elements.spinner.classList.add("scrolling");
+      this.state.scrollTimer = setTimeout(() => {
+        this.elements.spinner.classList.remove("scrolling");
+        this.elements.spinner.dispatchEvent(new CustomEvent("ScrollStop"));
+      }, SCROLL_TIMEOUT);
     },
 
     /**
      * Updates the spinner items to the current states.
      */
     _updateItems() {
       const { viewportSize, viewportTopOffset } = this.props;
       const { items, isInfiniteScroll } = this.state;
@@ -186,35 +205,46 @@ function Spinner(props, context) {
      * Make sure the number or child elements is the same as length
      * and keep the elements' references for updating textContent
      *
      * @param {Number} length [The number of child elements]
      * @param {DOMElement} parent [The parent element reference]
      */
     _prepareNodes(length, parent) {
       const diff = length - parent.childElementCount;
-      let count = Math.abs(diff);
+
+      if (!diff) {
+        return;
+      }
 
       if (diff > 0) {
         // Add more elements if length is greater than current
         let frag = document.createDocumentFragment();
 
-        for (let i = 0; i < count; i++) {
+        // Remove margin bottom on the last element before appending
+        if (parent.lastChild) {
+          parent.lastChild.style.marginBottom = "";
+        }
+
+        for (let i = 0; i < diff; i++) {
           let el = document.createElement("div");
           frag.appendChild(el);
           this.elements.itemsViewElements.push(el);
         }
         parent.appendChild(frag);
       } else if (diff < 0) {
         // Remove elements if length is less than current
-        for (let i = 0; i < count; i++) {
+        for (let i = 0; i < Math.abs(diff); i++) {
           parent.removeChild(parent.lastChild);
         }
         this.elements.itemsViewElements.splice(diff);
       }
+
+      parent.lastChild.style.marginBottom =
+        (ITEM_HEIGHT * this.props.viewportTopOffset) + "rem";
     },
 
     /**
      * Set the display string and class name to the elements.
      *
      * @param {Array<Object>} items
      *        [{
      *          {Number/String} value: The value in its original form
@@ -255,37 +285,44 @@ function Spinner(props, context) {
       switch (event.type) {
         case "scroll": {
           this._onScroll();
           break;
         }
         case "mousedown": {
           // Use preventDefault to keep focus on input boxes
           event.preventDefault();
+          event.target.setCapture();
           this.state.mouseState = {
             down: true,
             layerX: event.layerX,
             layerY: event.layerY
           };
           if (event.target == up) {
+            // An "active" class is needed to simulate :active pseudo-class
+            // because element is not focused.
+            event.target.classList.add("active");
             this._smoothScrollToIndex(index + 1);
           }
           if (event.target == down) {
+            event.target.classList.add("active");
             this._smoothScrollToIndex(index - 1);
           }
           if (event.target.parentNode == spinner) {
             // Listen to dragging events
-            event.target.setCapture();
             spinner.addEventListener("mousemove", this, { passive: true });
             spinner.addEventListener("mouseleave", this, { passive: true });
           }
           break;
         }
         case "mouseup": {
           this.state.mouseState.down = false;
+          if (event.target == up || event.target == down) {
+            event.target.classList.remove("active");
+          }
           if (event.target.parentNode == spinner) {
             // Check if user clicks or drags, scroll to the item if clicked,
             // otherwise get the current index and smooth scroll there.
             if (event.layerX == mouseState.layerX && event.layerY == mouseState.layerY) {
               const newIndex = this._getIndexByOffset(event.target.offsetTop) - viewportTopOffset;
               if (index == newIndex) {
                 // Set value manually if the clicked element is already centered.
                 // This happens when the picker first opens, and user pick the
@@ -321,17 +358,17 @@ function Spinner(props, context) {
     },
 
     /**
      * Find the index by offset
      * @param {Number} offset: Offset value in pixel.
      * @return {Number}  Index number
      */
     _getIndexByOffset(offset) {
-      return Math.round(offset / this.props.itemHeight);
+      return Math.round(offset / (ITEM_HEIGHT * this.props.rootFontSize));
     },
 
     /**
      * Find the index of a value that is the closest to the current position.
      * If centering is true, find the index closest to the center.
      *
      * @param {Number/String} value: The value to find
      * @param {Boolean} centering: Whether or not to find the value closest to center
@@ -376,17 +413,17 @@ function Spinner(props, context) {
      * @param  {Number/String} value: Value to scroll to
      * @param  {Boolean} centering: Whether or not to scroll to center location
      */
     _scrollTo(value, centering) {
       const index = this._getScrollIndex(value, centering);
       // Do nothing if the value is not found
       if (index > -1) {
         this.state.index = index;
-        this.elements.spinner.scrollTop = this.state.index * this.props.itemHeight;
+        this.elements.spinner.scrollTop = this.state.index * ITEM_HEIGHT * this.props.rootFontSize;
       }
     },
 
     /**
      * Smooth scroll to a value.
      *
      * @param  {Number/String} value: Value to scroll to
      */
--- a/toolkit/content/widgets/timepicker.js
+++ b/toolkit/content/widgets/timepicker.js
@@ -1,21 +1,21 @@
 /* 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/. */
 
-'use strict';
+"use strict";
 
 function TimePicker(context) {
   this.context = context;
   this._attachEventListeners();
 }
 
 {
-  const debug = 0 ? console.log.bind(console, '[timepicker]') : function() {};
+  const debug = 0 ? console.log.bind(console, "[timepicker]") : function() {};
 
   const DAY_PERIOD_IN_HOURS = 12,
         SECOND_IN_MS = 1000,
         MINUTE_IN_MS = 60000,
         DAY_IN_MS = 86400000;
 
   TimePicker.prototype = {
     /**
@@ -56,19 +56,16 @@ function TimePicker(context) {
         min: this._parseTimeString(min) || new Date(0),
         max: this._parseTimeString(max) || new Date(DAY_IN_MS - 1),
         stepInMs: step ? step * SECOND_IN_MS : MINUTE_IN_MS,
         format: format || "12"
       });
       timeKeeper.setState({ hour: timerHour, minute: timerMinute });
 
       this.state = { timeKeeper };
-
-      // TODO: Resize picker based on zoom level
-      document.documentElement.style.fontSize = "10px";
     },
 
     /**
      * Convert a time string from DOM attribute to a date object.
      *
      * @param  {String} timeString: (ex. "10:30", "23:55", "12:34:56.789")
      * @return {Date/Boolean} Date object or false if date is invalid.
      */
@@ -113,30 +110,62 @@ function TimePicker(context) {
           setValue: wrapSetValueFn(value => {
             timeKeeper.setMinute(value);
             this.state.isMinuteSet = true;
           }),
           getDisplayString: minute => numberFormat(minute)
         }, this.context)
       };
 
+      this._insertLayoutElement({
+        tag: "div",
+        textContent: ":",
+        className: "colon",
+        insertBefore: this.components.minute.elements.container
+      });
+
       // The AM/PM spinner is only available in 12hr mode
       // TODO: Replace AM & PM string with localized string
       if (format == "12") {
         this.components.dayPeriod = new Spinner({
           setValue: wrapSetValueFn(value => {
             timeKeeper.setDayPeriod(value);
             this.state.isDayPeriodSet = true;
           }),
-          getDisplayString: dayPeriod => dayPeriod == 0 ? "AM" : "PM"
+          getDisplayString: dayPeriod => dayPeriod == 0 ? "AM" : "PM",
+          hideButtons: true
         }, this.context);
+
+        this._insertLayoutElement({
+          tag: "div",
+          className: "spacer",
+          insertBefore: this.components.dayPeriod.elements.container
+        });
       }
     },
 
     /**
+     * Insert element for layout purposes.
+     *
+     * @param {Object}
+     *        {
+     *          {String} tag: The tag to create
+     *          {DOMElement} insertBefore: The DOM node to insert before
+     *          {String} className [optional]: Class name
+     *          {String} textContent [optional]: Text content
+     *        }
+     */
+    _insertLayoutElement({ tag, insertBefore, className, textContent }) {
+      let el = document.createElement(tag);
+      el.textContent = textContent;
+      el.className = className;
+      this.context.insertBefore(el, insertBefore);
+    },
+
+    /**
      * Set component states.
      */
     _setComponentStates() {
       const { timeKeeper, isHourSet, isMinuteSet, isDayPeriodSet } = this.state;
       const isInvalid = timeKeeper.state.isInvalid;
       // Value is set to min if it's first opened and time state is invalid
       const setToMinValue = !isHourSet && !isMinuteSet && !isDayPeriodSet && isInvalid;
 
@@ -183,17 +212,17 @@ function TimePicker(context) {
           minute,
           isHourSet,
           isMinuteSet,
           isDayPeriodSet
         }
       }, "*");
     },
     _attachEventListeners() {
-      window.addEventListener('message', this);
+      window.addEventListener("message", this);
     },
 
     /**
      * Handle events.
      *
      * @param  {Event} event
      */
     handleEvent(event) {
--- a/toolkit/themes/shared/timepicker.css
+++ b/toolkit/themes/shared/timepicker.css
@@ -1,88 +1,153 @@
 /* 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/. */
 
 :root {
-  --font-size: 1.2rem;
-  --spinner-item-height: 2rem;
-  --spinner-width: 5rem;
-  --spinner-height: 10rem;
-  --scroller-width: 1.5rem;
-  --disabled-color: #ccc;
-  --selected-color: #fff;
-  --selected-bgcolor: #83BFFC;
-  --hover-bgcolor: #aaa;
-  --hover-outline: #999;
+  --font-size-default: 1.1rem;
+  --spinner-width: 3rem;
+  --spinner-margin-top-bottom: 0.4rem;
+  --spinner-item-height: 2.4rem;
+  --spinner-item-margin-bottom: 0.1rem;
+  --spinner-button-height: 1.2rem;
+  --colon-width: 2rem;
+  --day-period-spacing-width: 1rem;
+
+  --border: 0.1rem solid #D6D6D6;
+  --border-radius: 0.3rem;
+
+  --font-color: #191919;
+  --fill-color: #EBEBEB;
+
+  --selected-font-color: #FFFFFF;
+  --selected-fill-color: #0996F8;
+
+  --button-font-color: #858585;
+  --button-font-color-hover: #4D4D4D;
+  --button-font-color-active: #191919;
+
+  --disabled-opacity: 0.2;
+}
+
+html {
+  font-size: 10px;
 }
 
 body {
   margin: 0;
-  font-size: var(--font-size);
+  color: var(--font-color);
+  font-size: var(--font-size-default);
 }
 
 #time-picker {
   display: flex;
   flex-direction: row;
+  justify-content: space-around;
 }
 
 .spinner-container {
   font-family: sans-serif;
   display: flex;
   flex-direction: column;
   width: var(--spinner-width);
 }
 
-.spinner-container button {
+.spinner-container > button {
   -moz-appearance: none;
   border: none;
   background: none;
-  height: var(--spinner-item-height);
+  background-color: var(--button-font-color);
+  height: var(--spinner-button-height);
+}
+
+.spinner-container > button:hover {
+  background-color: var(--button-font-color-hover);
+}
+
+.spinner-container > button.active {
+  background-color: var(--button-font-color-active);
 }
 
-.spinner-container .stack {
-  position: relative;
-  height: var(--spinner-height);
+.spinner-container > button.up {
+  mask: url("chrome://global/skin/icons/find-arrows.svg#glyph-find-previous") no-repeat 50% 50%;
+}
+
+.spinner-container > button.down {
+  mask: url("chrome://global/skin/icons/find-arrows.svg#glyph-find-next") no-repeat 50% 50%;
 }
 
-.spinner-container .spinner {
-  position: absolute;
-  height: var(--spinner-height);
+.spinner-container.hide-buttons > button {
+  visibility: hidden;
+}
+
+.spinner-container > .spinner {
+  position: relative;
   width: 100%;
+  margin: var(--spinner-margin-top-bottom) 0;
   cursor: default;
   overflow-y: scroll;
   scroll-snap-type: mandatory;
   scroll-snap-points-y: repeat(100%);
 }
 
-.spinner-container .spinner > div {
+.spinner-container > .spinner > div {
+  box-sizing: border-box;
   position: relative;
   text-align: center;
-  padding: calc(var(--spinner-item-height) / 4) 0;
-  height: calc(var(--spinner-item-height) / 2);
+  padding: calc((var(--spinner-item-height) - var(--font-size-default)) / 2) 0;
+  margin-bottom: var(--spinner-item-margin-bottom);
+  height: var(--spinner-item-height);
   -moz-user-select: none;
   scroll-snap-coordinate: 0 0;
 }
 
-.spinner-container .spinner > div:last-child {
-  margin-bottom: calc(var(--spinner-item-height) * 2);
-}
-
-.spinner-container .spinner > div.selection {
-  color: var(--selected-color);
-}
-
-.spinner-container .spinner > div.selection::before {
+.spinner-container > .spinner > div:hover::before {
+  background: var(--fill-color);
+  border: var(--border);
+  border-radius: var(--border-radius);
   content: "";
-  background: var(--selected-bgcolor);
   position: absolute;
-  top: 5%;
-  bottom: 5%;
-  left: 10%;
-  right: 10%;
-  border-radius: 5%;
+  top: 0%;
+  bottom: 0%;
+  left: 0%;
+  right: 0%;
   z-index: -10;
 }
 
-.spinner-container .spinner > div.disabled {
-  color: var(--disabled-color);
+.spinner-container > .spinner:not(.scrolling) > div.selection {
+  color: var(--selected-font-color);
+}
+
+.spinner-container > .spinner > div.selection::before {
+  background: var(--selected-fill-color);
+  border: none;
+  border-radius: var(--border-radius);
+  content: "";
+  position: absolute;
+  top: 0%;
+  bottom: 0%;
+  left: 0%;
+  right: 0%;
+  z-index: -10;
 }
+
+.spinner-container > .spinner > div.disabled::before,
+.spinner-container > .spinner.scrolling > div.selection::before,
+.spinner-container > .spinner.scrolling > div:hover::before {
+  display: none;
+}
+
+.spinner-container > .spinner > div.disabled {
+  opacity: var(--disabled-opacity);
+}
+
+.colon {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  width: var(--colon-width);
+  margin-bottom: 0.3rem;
+}
+
+.spacer {
+  width: var(--day-period-spacing-width);
+}
\ No newline at end of file