Bug 1283384 - Implement time picker UI w/ message passing, r=mconley
authorScott Wu <scottcwwu@gmail.com>
Tue, 06 Sep 2016 13:01:40 +0800
changeset 319276 b20258d9a61bd4a746d7bdcb3026b4adb2e0c7ad
parent 319275 5283a6d1c99f4bef96db734a83487b8569dd4c9f
child 319277 6a52e0212b16b9115a688420872c81ab15cfd594
push id30869
push userphilringnalda@gmail.com
push dateWed, 26 Oct 2016 04:57:48 +0000
treeherdermozilla-central@9471b3c49b2c [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmconley
bugs1283384
milestone52.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 1283384 - Implement time picker UI w/ message passing, r=mconley MozReview-Commit-ID: Gn3Itf0yFrN
browser/base/content/browser.css
browser/base/content/browser.xul
toolkit/content/browser-content.js
toolkit/content/jar.mn
toolkit/content/timepicker.xhtml
toolkit/content/widgets/datetimepopup.xml
toolkit/content/widgets/spinner.js
toolkit/content/widgets/timekeeper.js
toolkit/content/widgets/timepicker.js
toolkit/modules/DateTimePickerHelper.jsm
toolkit/themes/shared/jar.inc.mn
toolkit/themes/shared/timepicker.css
--- a/browser/base/content/browser.css
+++ b/browser/base/content/browser.css
@@ -515,16 +515,20 @@ toolbar:not(#TabsToolbar) > #personal-bo
 #PopupAutoCompleteRichResult > richlistbox {
   transition: height 100ms;
 }
 
 #PopupAutoCompleteRichResult.showSearchSuggestionsNotification > richlistbox {
   transition: none;
 }
 
+#DateTimePickerPanel {
+  -moz-binding: url("chrome://global/content/bindings/datetimepopup.xml#datetime-popup");
+}
+
 #urlbar[pageproxystate="invalid"] > #urlbar-icons > .urlbar-icon,
 #urlbar[pageproxystate="invalid"][focused="true"] > #urlbar-go-button ~ toolbarbutton,
 #urlbar[pageproxystate="valid"] > #urlbar-go-button,
 #urlbar:not([focused="true"]) > #urlbar-go-button {
   visibility: collapse;
 }
 
 #urlbar[pageproxystate="invalid"] > #identity-box > #identity-icon-labels {
--- a/browser/base/content/browser.xul
+++ b/browser/base/content/browser.xul
@@ -150,20 +150,24 @@
     <panel type="autocomplete-richlistbox"
            id="PopupAutoCompleteRichResult"
            noautofocus="true"
            hidden="true"
            flip="none"
            level="parent"/>
 
     <panel id="DateTimePickerPanel"
+           type="arrow"
            hidden="true"
+           orient="vertical"
            noautofocus="true"
            consumeoutsideclicks="false"
-           level="parent"/>
+           level="parent">
+      <iframe id="dateTimePopupFrame"/>
+    </panel>
 
     <!-- for select dropdowns. The menupopup is what shows the list of options,
          and the popuponly menulist makes things like the menuactive attributes
          work correctly on the menupopup. ContentSelectDropdown expects the
          popuponly menulist to be its immediate parent. -->
     <menulist popuponly="true" id="ContentSelectDropdown" hidden="true">
       <menupopup rolluponmousewheel="true"
                  activateontab="true" position="after_start"
--- a/toolkit/content/browser-content.js
+++ b/toolkit/content/browser-content.js
@@ -1635,20 +1635,21 @@ let DateTimePickerListener = {
    */
   getComputedDirection: function(aElement) {
     return aElement.ownerDocument.defaultView.getComputedStyle(aElement)
       .getPropertyValue("direction");
   },
 
   /**
    * Helper function that returns the rect of the element, which is the position
-   * in "screen" coordinates.
+   * relative to the left/top of the content area.
    */
   getBoundingContentRect: function(aElement) {
-    return BrowserUtils.getElementBoundingScreenRect(aElement);
+    return BrowserUtils.getElementBoundingRect(aElement);
+    // return BrowserUtils.getElementBoundingScreenRect(aElement);
   },
 
   /**
    * nsIMessageListener.
    */
   receiveMessage: function(aMessage) {
     switch (aMessage.name) {
       case "FormDateTime:PickerClosed": {
--- a/toolkit/content/jar.mn
+++ b/toolkit/content/jar.mn
@@ -58,24 +58,26 @@ toolkit.jar:
    content/global/mozilla.xhtml
    content/global/process-content.js
    content/global/resetProfile.css
    content/global/resetProfile.js
    content/global/resetProfile.xul
    content/global/resetProfileProgress.xul
    content/global/select-child.js
    content/global/TopLevelVideoDocument.js
+   content/global/timepicker.xhtml
    content/global/treeUtils.js
    content/global/viewZoomOverlay.js
    content/global/bindings/autocomplete.xml    (widgets/autocomplete.xml)
    content/global/bindings/browser.xml         (widgets/browser.xml)
    content/global/bindings/button.xml          (widgets/button.xml)
    content/global/bindings/checkbox.xml        (widgets/checkbox.xml)
    content/global/bindings/colorpicker.xml     (widgets/colorpicker.xml)
    content/global/bindings/datetimepicker.xml  (widgets/datetimepicker.xml)
+   content/global/bindings/datetimepopup.xml   (widgets/datetimepopup.xml)
    content/global/bindings/datetimebox.xml     (widgets/datetimebox.xml)
    content/global/bindings/datetimebox.css     (widgets/datetimebox.css)
 *  content/global/bindings/dialog.xml          (widgets/dialog.xml)
    content/global/bindings/editor.xml          (widgets/editor.xml)
    content/global/bindings/expander.xml        (widgets/expander.xml)
    content/global/bindings/filefield.xml       (widgets/filefield.xml)
 *  content/global/bindings/findbar.xml         (widgets/findbar.xml)
    content/global/bindings/general.xml         (widgets/general.xml)
@@ -90,22 +92,25 @@ toolkit.jar:
    content/global/bindings/progressmeter.xml   (widgets/progressmeter.xml)
    content/global/bindings/radio.xml           (widgets/radio.xml)
    content/global/bindings/remote-browser.xml  (widgets/remote-browser.xml)
    content/global/bindings/resizer.xml         (widgets/resizer.xml)
    content/global/bindings/richlistbox.xml     (widgets/richlistbox.xml)
    content/global/bindings/scale.xml           (widgets/scale.xml)
    content/global/bindings/scrollbar.xml       (widgets/scrollbar.xml)
    content/global/bindings/scrollbox.xml       (widgets/scrollbox.xml)
+   content/global/bindings/spinner.js          (widgets/spinner.js)
    content/global/bindings/splitter.xml        (widgets/splitter.xml)
    content/global/bindings/spinbuttons.xml     (widgets/spinbuttons.xml)
    content/global/bindings/stringbundle.xml    (widgets/stringbundle.xml)
 *  content/global/bindings/tabbox.xml          (widgets/tabbox.xml)
    content/global/bindings/text.xml            (widgets/text.xml)
 *  content/global/bindings/textbox.xml         (widgets/textbox.xml)
+   content/global/bindings/timekeeper.js       (widgets/timekeeper.js)
+   content/global/bindings/timepicker.js       (widgets/timepicker.js)
    content/global/bindings/toolbar.xml         (widgets/toolbar.xml)
    content/global/bindings/toolbarbutton.xml   (widgets/toolbarbutton.xml)
 *  content/global/bindings/tree.xml            (widgets/tree.xml)
    content/global/bindings/videocontrols.xml   (widgets/videocontrols.xml)
    content/global/bindings/videocontrols.css   (widgets/videocontrols.css)
 *  content/global/bindings/wizard.xml          (widgets/wizard.xml)
 #ifdef XP_MACOSX
    content/global/macWindowMenu.js
new file mode 100644
--- /dev/null
+++ b/toolkit/content/timepicker.xhtml
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE html [
+  <!ENTITY % htmlDTD PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "DTD/xhtml1-strict.dtd">
+  %htmlDTD;
+]>
+<html xmlns="http://www.w3.org/1999/xhtml" xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+<head>
+  <title>Time Picker</title>
+  <link rel="stylesheet" href="chrome://global/skin/timepicker.css"/>
+  <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>
+    </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).
+                  getInterface(Components.interfaces.nsIDOMWindowUtils);
+  domWinUtls.loadSheetUsingURIString('data:text/css,@namespace url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"); scrollbar { display: none; }', domWinUtls.AGENT_SHEET);
+  // Create a TimePicker instance and prepare to be
+  // initialized by the "TimePickerInit" event from timepicker.xml
+  new TimePicker(document.getElementById("time-picker"));
+  </script>
+</body>
+</html>
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/toolkit/content/widgets/datetimepopup.xml
@@ -0,0 +1,179 @@
+<?xml version="1.0"?>
+
+<!-- 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/. -->
+
+<bindings id="dateTimePopupBindings"
+   xmlns="http://www.mozilla.org/xbl"
+   xmlns:html="http://www.w3.org/1999/xhtml"
+   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>
+      <method name="loadPicker">
+        <parameter name="type"/>
+        <parameter name="detail"/>
+        <body><![CDATA[
+          this.hidden = false;
+          this.type = type;
+          this.pickerState = {};
+          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;
+            }
+          }
+        ]]></body>
+      </method>
+      <method name="closePicker">
+        <body><![CDATA[
+          this.hidden = true;
+          this.setInputBoxValue(true);
+          this.pickerState = {};
+          this.type = undefined;
+          this.dateTimePopupFrame.removeEventListener("load", this, true);
+          this.dateTimePopupFrame.contentDocument.removeEventListener("TimePickerPopupChanged", this, false);
+          this.dateTimePopupFrame.setAttribute("src", "");
+        ]]></body>
+      </method>
+      <method name="setPopupValue">
+        <parameter name="data"/>
+        <body><![CDATA[
+          switch (this.type) {
+            case "time": {
+              this.postMessageToPicker({
+                name: "TimePickerSetValue",
+                detail: data.value
+              });
+              break;
+            }
+          }
+        ]]></body>
+      </method>
+      <method name="initPicker">
+        <parameter name="detail"/>
+        <body><![CDATA[
+          switch (this.type) {
+            case "time": {
+              const { hour, minute } = detail.value;
+              const format = detail.format || "12";
+              const locale = Components.classes["@mozilla.org/chrome/chrome-registry;1"].getService(Ci.nsIXULChromeRegistry).getSelectedLocale("global");
+
+              this.postMessageToPicker({
+                name: "TimePickerInit",
+                detail: {
+                  hour,
+                  minute,
+                  format,
+                  locale,
+                  min: detail.min,
+                  max: detail.max,
+                  step: detail.step,
+                }
+              });
+              break;
+            }
+          }
+        ]]></body>
+      </method>
+      <method name="setInputBoxValue">
+        <parameter name="passAllValues"/>
+        <body><![CDATA[
+          /**
+           * @param {Boolean} passAllValues: Pass spinner values regardless if they've been set/changed or not
+           */
+          switch (this.type) {
+            case "time": {
+              const { hour, minute, isHourSet, isMinuteSet, isDayPeriodSet } = this.pickerState;
+              const isAnyValueSet = isHourSet || isMinuteSet || isDayPeriodSet;
+              if (passAllValues && isAnyValueSet) {
+                this.sendPickerValueChanged({ hour, minute });
+              } else {
+                this.sendPickerValueChanged({
+                  hour: isHourSet || isDayPeriodSet ? hour : undefined,
+                  minute: isMinuteSet ? minute : undefined
+                });
+              }
+              break;
+            }
+          }
+        ]]></body>
+      </method>
+      <method name="sendPickerValueChanged">
+        <parameter name="value"/>
+        <body><![CDATA[
+          switch (this.type) {
+            case "time": {
+              this.dispatchEvent(new CustomEvent("DateTimePickerValueChanged", {
+                detail: {
+                  hour: value.hour,
+                  minute: value.minute
+                }
+              }));
+              break;
+            }
+          }
+        ]]></body>
+      </method>
+      <method name="handleEvent">
+        <parameter name="aEvent"/>
+        <body><![CDATA[
+          switch (aEvent.type) {
+            case "load": {
+              this.initPicker(this.detail);
+              this.dateTimePopupFrame.contentWindow.addEventListener("message", this, false);
+              break;
+            }
+            case "message": {
+              this.handleMessage(aEvent);
+              break;
+            }
+          }
+        ]]></body>
+      </method>
+      <method name="handleMessage">
+        <parameter name="aEvent"/>
+        <body><![CDATA[
+          if (!this.dateTimePopupFrame.contentDocument.nodePrincipal.isSystemPrincipal) {
+            return;
+          }
+
+          switch (aEvent.data.name) {
+            case "TimePickerPopupChanged": {
+              this.pickerState = aEvent.data.detail;
+              this.setInputBoxValue();
+              break;
+            }
+          }
+        ]]></body>
+      </method>
+      <method name="postMessageToPicker">
+        <parameter name="data"/>
+        <body><![CDATA[
+          if (this.dateTimePopupFrame.contentDocument.nodePrincipal.isSystemPrincipal) {
+            this.dateTimePopupFrame.contentWindow.postMessage(data, "*");
+          }
+        ]]></body>
+      </method>
+
+    </implementation>
+    <handlers>
+      <handler event="popuphiding">
+        <![CDATA[
+          this.closePicker();
+        ]]>
+      </handler>
+    </handlers>
+  </binding>
+</bindings>
new file mode 100644
--- /dev/null
+++ b/toolkit/content/widgets/spinner.js
@@ -0,0 +1,477 @@
+/* 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";
+
+/*
+ * The spinner is responsible for displaying the items, and does
+ * not care what the values represent. The setValue function is called
+ * when it detects a change in value triggered by scroll event.
+ * Supports scrolling, clicking on up or down, clicking on item, and
+ * dragging.
+ */
+
+function Spinner(props, context) {
+  this.context = context;
+  this._init(props);
+};
+
+{
+  const debug = 0 ? console.log.bind(console, '[spinner]') : function() {};
+
+  const ITEM_HEIGHT = 20,
+        VIEWPORT_SIZE = 5,
+        VIEWPORT_COUNT = 5;
+
+  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.
+     *         }
+     */
+    _init(props) {
+      const { setValue, getDisplayString, itemHeight = ITEM_HEIGHT } = 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,
+        // 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 = {
+        spinner: spinnerElement.querySelector(".spinner"),
+        up: spinnerElement.querySelector(".up"),
+        down: spinnerElement.querySelector(".down"),
+        itemsViewElements: []
+      };
+
+      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.
+     *
+     * @param {Object} newState [The new spinner state]
+     *        {
+     *          {Number/String} value: The centered value
+     *          {Array} items: The list of items for display
+     *          {Boolean} isInfiniteScroll: Whether or not the spinner should
+     *            have infinite scroll capability
+     *          {Boolean} isValueSet: true if user has selected a value
+     *        }
+     */
+    setState(newState) {
+      const { spinner } = this.elements;
+      const { value, items } = this.state;
+      const { value: newValue, items: newItems, isValueSet, isInvalid } = newState;
+
+      if (this._isArrayDiff(newItems, items)) {
+        this.state = Object.assign(this.state, newState);
+        this._updateItems();
+        this._scrollTo(newValue, true);
+      } else if (newValue != value) {
+        this.state = Object.assign(this.state, newState);
+        this._smoothScrollTo(newValue);
+      }
+
+      if (isValueSet) {
+        if (isInvalid) {
+          this._removeSelection();
+        } else {
+          this._updateSelection();
+        }
+      }
+    },
+
+    /**
+     * Whenever scroll event is detected:
+     * - 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 { 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.
+      if (this.state.value == value && this.state.isScrolling) {
+        this.state.isScrolling = false;
+      }
+
+      // Call setValue if value has changed, and is not smooth scrolling
+      if (this.state.value != value && !this.state.isScrolling) {
+        this.state.value = value;
+        this.props.setValue(value);
+      }
+
+      // Do infinite scroll when items length is bigger or equal to viewport
+      // and isInfiniteScroll is not false.
+      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);
+        }
+      }
+    },
+
+    /**
+     * Updates the spinner items to the current states.
+     */
+    _updateItems() {
+      const { viewportSize, viewportTopOffset } = this.props;
+      const { items, isInfiniteScroll } = this.state;
+
+      // Prepends null elements so the selected value is centered in spinner
+      let itemsView = new Array(viewportTopOffset).fill({}).concat(items);
+
+      if (items.length >= viewportSize && isInfiniteScroll) {
+        // To achieve infinite scroll, we move the scroll position back to the
+        // center when it is near the top or bottom. The scroll momentum could
+        // be lost in the process, so to minimize that, we need at least 2 sets
+        // of items to act as buffer: one for the top and one for the bottom.
+        // But if the number of items is small ( < viewportSize * viewport count)
+        // we should add more sets.
+        let count = Math.ceil(viewportSize * VIEWPORT_COUNT / items.length) * 2;
+        for (let i = 0; i < count; i += 1) {
+          itemsView.push(...items);
+        }
+      }
+
+      // Reuse existing DOM nodes when possible. Create or remove
+      // nodes based on how big itemsView is.
+      this._prepareNodes(itemsView.length, this.elements.spinner);
+      // Once DOM nodes are ready, set display strings using textContent
+      this._setDisplayStringAndClass(itemsView, this.elements.itemsViewElements);
+
+      this.state.itemsView = itemsView;
+    },
+
+    /**
+     * 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 > 0) {
+        // Add more elements if length is greater than current
+        let frag = document.createDocumentFragment();
+
+        for (let i = 0; i < count; 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++) {
+          parent.removeChild(parent.lastChild);
+        }
+        this.elements.itemsViewElements.splice(diff);
+      }
+    },
+
+    /**
+     * Set the display string and class name to the elements.
+     *
+     * @param {Array<Object>} items
+     *        [{
+     *          {Number/String} value: The value in its original form
+     *          {Boolean} enabled: Whether or not the item is enabled
+     *        }]
+     * @param {Array<DOMElement>} elements
+     */
+    _setDisplayStringAndClass(items, elements) {
+      const { getDisplayString } = this.props;
+
+      items.forEach((item, index) => {
+        elements[index].textContent =
+          item.value != undefined ? getDisplayString(item.value) : "";
+        elements[index].className = item.enabled ? "" : "disabled";
+      });
+    },
+
+    /**
+     * Attach event listeners to the spinner and buttons.
+     */
+    _attachEventListeners() {
+      const { spinner } = this.elements;
+
+      spinner.addEventListener("scroll", this, { passive: true });
+      document.addEventListener("mouseup", this, { passive: true });
+      document.addEventListener("mousedown", this);
+    },
+
+    /**
+     * Handle events
+     * @param  {DOMEvent} event
+     */
+    handleEvent(event) {
+      const { mouseState = {}, index, itemsView } = this.state;
+      const { viewportTopOffset, setValue } = this.props;
+      const { spinner, up, down } = this.elements;
+
+      switch (event.type) {
+        case "scroll": {
+          this._onScroll();
+          break;
+        }
+        case "mousedown": {
+          // Use preventDefault to keep focus on input boxes
+          event.preventDefault();
+          this.state.mouseState = {
+            down: true,
+            layerX: event.layerX,
+            layerY: event.layerY
+          };
+          if (event.target == up) {
+            this._smoothScrollToIndex(index + 1);
+          }
+          if (event.target == down) {
+            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.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
+                // default value.
+                setValue(itemsView[index + viewportTopOffset].value);
+              } else {
+                this._smoothScrollToIndex(newIndex);
+              }
+            } else {
+              this._smoothScrollToIndex(this._getIndexByOffset(spinner.scrollTop));
+            }
+            // Stop listening to dragging
+            spinner.removeEventListener("mousemove", this, { passive: true });
+            spinner.removeEventListener("mouseleave", this, { passive: true });
+          }
+          break;
+        }
+        case "mouseleave": {
+          if (event.target == spinner) {
+            // Stop listening to drag event if mouse is out of the spinner
+            this._smoothScrollToIndex(this._getIndexByOffset(spinner.scrollTop));
+            spinner.removeEventListener("mousemove", this, { passive: true });
+            spinner.removeEventListener("mouseleave", this, { passive: true });
+          }
+          break;
+        }
+        case "mousemove": {
+          // Change spinner position on drag
+          spinner.scrollTop -= event.movementY;
+          break;
+        }
+      }
+    },
+
+    /**
+     * 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);
+    },
+
+    /**
+     * 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
+     * @return {Number} index of the value, returns -1 if value is not found
+     */
+    _getScrollIndex(value, centering) {
+      const { itemsView } = this.state;
+      const { viewportTopOffset } = this.props;
+
+      // If index doesn't exist, or centering is true, start from the middle point
+      let currentIndex = centering || (this.state.index == undefined) ?
+                         Math.round((itemsView.length - viewportTopOffset) / 2) :
+                         this.state.index;
+      let closestIndex = itemsView.length;
+      let indexes = [];
+      let diff = closestIndex;
+      let isValueFound = false;
+
+      // Find indexes of items match the value
+      itemsView.forEach((item, index) => {
+        if (item.value == value) {
+          indexes.push(index);
+        }
+      });
+
+      // Find the index closest to currentIndex
+      indexes.forEach(index => {
+        let d = Math.abs(index - currentIndex);
+        if (d < diff) {
+          diff = d;
+          closestIndex = index;
+          isValueFound = true;
+        }
+      });
+
+      return isValueFound ? (closestIndex - viewportTopOffset) : -1;
+    },
+
+    /**
+     * Scroll to a value.
+     *
+     * @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;
+      }
+    },
+
+    /**
+     * Smooth scroll to a value.
+     *
+     * @param  {Number/String} value: Value to scroll to
+     */
+    _smoothScrollTo(value) {
+      const index = this._getScrollIndex(value);
+      // Do nothing if the value is not found
+      if (index > -1) {
+        this.state.index = index;
+        this._smoothScrollToIndex(this.state.index);
+      }
+    },
+
+    /**
+     * Smooth scroll to a value based on the index
+     *
+     * @param  {Number} index: Index number
+     */
+    _smoothScrollToIndex(index) {
+      const element = this.elements.spinner.children[index];
+      if (element) {
+        // Set the isScrolling flag before smooth scrolling begins
+        // and remove it when it has reached the destination.
+        // This prevents input box jump when input box changes values
+        this.state.isScrolling = true;
+        element.scrollIntoView({
+          behavior: "smooth", block: "start"
+        });
+      }
+    },
+
+    /**
+     * Update the selection state.
+     */
+    _updateSelection() {
+      const { itemsViewElements, selected } = this.elements;
+      const { itemsView, index } = this.state;
+      const { viewportTopOffset } = this.props;
+      const currentItemIndex = index + viewportTopOffset;
+
+      if (selected && selected != itemsViewElements[currentItemIndex]) {
+        this._removeSelection();
+      }
+
+      this.elements.selected = itemsViewElements[currentItemIndex];
+      if (itemsView[currentItemIndex] && itemsView[currentItemIndex].enabled) {
+        this.elements.selected.classList.add("selection");
+      }
+    },
+
+    /**
+     * Remove selection if selected exists and different from current
+     */
+    _removeSelection() {
+      const { selected } = this.elements;
+      if (selected) {
+        selected.classList.remove("selection");
+      }
+    },
+
+    /**
+     * Compares arrays of objects. It assumes the structure is an array of
+     * objects, and objects in a and b have the same number of properties.
+     *
+     * @param  {Array<Object>} a
+     * @param  {Array<Object>} b
+     * @return {Boolean}  Returns true if a and b are different
+     */
+    _isArrayDiff(a, b) {
+      // Check reference first, exit early if reference is the same.
+      if (a == b) {
+        return false;
+      }
+
+      if (a.length != b.length) {
+        return true;
+      }
+
+      for (let i = 0; i < a.length; i++) {
+        for (let prop in a[i]) {
+          if (a[i][prop] != b[i][prop]) {
+            return true;
+          }
+        }
+      }
+      return false;
+    }
+  };
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/content/widgets/timekeeper.js
@@ -0,0 +1,418 @@
+/* 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";
+
+/**
+ * TimeKeeper keeps track of the time states. Given min, max, step, and
+ * format (12/24hr), TimeKeeper will determine the ranges of possible
+ * selections, and whether or not the current time state is out of range
+ * or off step.
+ *
+ * @param {Object} props
+ *        {
+ *          {Date} min
+ *          {Date} max
+ *          {Number} stepInMs
+ *          {String} format: Either "12" or "24"
+ *        }
+ */
+function TimeKeeper(props) {
+  this.props = props;
+  this.state = { time: new Date(0), ranges: {} };
+};
+
+{
+  const debug = 0 ? console.log.bind(console, '[timekeeper]') : function() {};
+
+  const DAY_PERIOD_IN_HOURS = 12,
+        SECOND_IN_MS = 1000,
+        MINUTE_IN_MS = 60000,
+        HOUR_IN_MS = 3600000,
+        DAY_PERIOD_IN_MS = 43200000,
+        DAY_IN_MS = 86400000,
+        TIME_FORMAT_24 = "24";
+
+  TimeKeeper.prototype = {
+    /**
+     * Getters for different time units.
+     * @return {Number}
+     */
+    get hour() {
+      return this.state.time.getUTCHours();
+    },
+    get minute() {
+      return this.state.time.getUTCMinutes();
+    },
+    get second() {
+      return this.state.time.getUTCSeconds();
+    },
+    get millisecond() {
+      return this.state.time.getUTCMilliseconds();
+    },
+    get dayPeriod() {
+      // 0 stands for AM and 12 for PM
+      return this.state.time.getUTCHours() < DAY_PERIOD_IN_HOURS ? 0 : DAY_PERIOD_IN_HOURS;
+    },
+
+    /**
+     * Get the ranges of different time units.
+     * @return {Object}
+     *         {
+     *           {Array<Number>} dayPeriod
+     *           {Array<Number>} hours
+     *           {Array<Number>} minutes
+     *           {Array<Number>} seconds
+     *           {Array<Number>} milliseconds
+     *         }
+     */
+    get ranges() {
+      return this.state.ranges;
+    },
+
+    /**
+     * Set new time, check if the current state is valid, and set ranges.
+     *
+     * @param {Object} timeState: The new time
+     *        {
+     *          {Number} hour [optional]
+     *          {Number} minute [optional]
+     *          {Number} second [optional]
+     *          {Number} millisecond [optional]
+     *        }
+     */
+    setState(timeState) {
+      const { min, max } = this.props;
+      const { hour, minute, second, millisecond } = timeState;
+
+      if (hour != undefined) {
+        this.state.time.setUTCHours(hour);
+      }
+      if (minute != undefined) {
+        this.state.time.setUTCMinutes(minute);
+      }
+      if (second != undefined) {
+        this.state.time.setUTCSeconds(second);
+      }
+      if (millisecond != undefined) {
+        this.state.time.setUTCMilliseconds(millisecond);
+      }
+
+      this.state.isOffStep = this._isOffStep(this.state.time);
+      this.state.isOutOfRange = (this.state.time < min || this.state.time > max);
+      this.state.isInvalid = this.state.isOutOfRange || this.state.isOffStep;
+
+      this._setRanges(this.dayPeriod, this.hour, this.minute, this.second);
+    },
+
+    /**
+     * Set day-period (AM/PM)
+     * @param {Number} dayPeriod: 0 as AM, 12 as PM
+     */
+    setDayPeriod(dayPeriod) {
+      if (dayPeriod == this.dayPeriod) {
+        return;
+      }
+
+      if (dayPeriod == 0) {
+        this.setState({ hour: this.hour - DAY_PERIOD_IN_HOURS });
+      } else {
+        this.setState({ hour: this.hour + DAY_PERIOD_IN_HOURS });
+      }
+    },
+
+    /**
+     * Set hour in 24hr format (0 ~ 23)
+     * @param {Number} hour
+     */
+    setHour(hour) {
+      this.setState({ hour });
+    },
+
+    /**
+     * Set minute (0 ~ 59)
+     * @param {Number} minute
+     */
+    setMinute(minute) {
+      this.setState({ minute });
+    },
+
+    /**
+     * Set second (0 ~ 59)
+     * @param {Number} second
+     */
+    setSecond(second) {
+      this.setState({ second });
+    },
+
+    /**
+     * Set millisecond (0 ~ 999)
+     * @param {Number} millisecond
+     */
+    setMillisecond(millisecond) {
+      this.setState({ millisecond });
+    },
+
+    /**
+     * Calculate the range of possible choices for each time unit.
+     * Reuse the old result if the input has not changed.
+     *
+     * @param {Number} dayPeriod
+     * @param {Number} hour
+     * @param {Number} minute
+     * @param {Number} second
+     */
+    _setRanges(dayPeriod, hour, minute, second) {
+      this.state.ranges.dayPeriod =
+        this.state.ranges.dayPeriod || this._getDayPeriodRange();
+
+      if (this.state.dayPeriod != dayPeriod) {
+        this.state.ranges.hours = this._getHoursRange(dayPeriod);
+      }
+
+      if (this.state.hour != hour) {
+        this.state.ranges.minutes = this._getMinutesRange(hour);
+      }
+
+      if (this.state.hour != hour || this.state.minute != minute) {
+        this.state.ranges.seconds = this._getSecondsRange(hour, minute);
+      }
+
+      if (this.state.hour != hour || this.state.minute != minute || this.state.second != second) {
+        this.state.ranges.milliseconds = this._getMillisecondsRange(hour, minute, second);
+      }
+
+      // Save the time states for comparison.
+      this.state.dayPeriod = dayPeriod;
+      this.state.hour = hour;
+      this.state.minute = minute;
+      this.state.second = second;
+    },
+
+    /**
+     * Get the AM/PM range. Return an empty array if in 24hr mode.
+     *
+     * @return {Array<Number>}
+     */
+    _getDayPeriodRange() {
+      if (this.props.format == TIME_FORMAT_24) {
+        return [];
+      }
+
+      const start = 0;
+      const end = DAY_IN_MS - 1;
+      const minStep = DAY_PERIOD_IN_MS;
+      const formatter = (time) =>
+        new Date(time).getUTCHours() < DAY_PERIOD_IN_HOURS ? 0 : DAY_PERIOD_IN_HOURS;
+
+      return this._getSteps(start, end, minStep, formatter);
+    },
+
+    /**
+     * Get the hours range.
+     *
+     * @param  {Number} dayPeriod
+     * @return {Array<Number>}
+     */
+    _getHoursRange(dayPeriod) {
+      const { format } = this.props;
+      const start = format == "24" ? 0 : dayPeriod * HOUR_IN_MS;
+      const end = format == "24" ? DAY_IN_MS - 1 : start + DAY_PERIOD_IN_MS - 1;
+      const minStep = HOUR_IN_MS;
+      const formatter = (time) => new Date(time).getUTCHours();
+
+      return this._getSteps(start, end, minStep, formatter);
+    },
+
+    /**
+     * Get the minutes range
+     *
+     * @param  {Number} hour
+     * @return {Array<Number>}
+     */
+    _getMinutesRange(hour) {
+      const start = hour * HOUR_IN_MS;
+      const end = start + HOUR_IN_MS - 1;
+      const minStep = MINUTE_IN_MS;
+      const formatter = (time) => new Date(time).getUTCMinutes();
+
+      return this._getSteps(start, end, minStep, formatter);
+    },
+
+    /**
+     * Get the seconds range
+     *
+     * @param  {Number} hour
+     * @param  {Number} minute
+     * @return {Array<Number>}
+     */
+    _getSecondsRange(hour, minute) {
+      const start = hour * HOUR_IN_MS + minute * MINUTE_IN_MS;
+      const end = start + MINUTE_IN_MS - 1;
+      const minStep = SECOND_IN_MS;
+      const formatter = (time) => new Date(time).getUTCSeconds();
+
+      return this._getSteps(start, end, minStep, formatter);
+    },
+
+    /**
+     * Get the milliseconds range
+     * @param  {Number} hour
+     * @param  {Number} minute
+     * @param  {Number} second
+     * @return {Array<Number>}
+     */
+    _getMillisecondsRange(hour, minute, second) {
+      const start = hour * HOUR_IN_MS + minute * MINUTE_IN_MS + second * SECOND_IN_MS;
+      const end = start + SECOND_IN_MS - 1;
+      const minStep = 1;
+      const formatter = (time) => new Date(time).getUTCMilliseconds();
+
+      return this._getSteps(start, end, minStep, formatter);
+    },
+
+    /**
+     * Calculate the range of possible steps.
+     *
+     * @param  {Number} startValue: Start time in ms
+     * @param  {Number} endValue: End time in ms
+     * @param  {Number} minStep: Smallest step in ms for the time unit
+     * @param  {Function} formatter: Outputs time in a particular format
+     * @return {Array<Object>}
+     *         {
+     *           {Number} value
+     *           {Boolean} enabled
+     *         }
+     */
+    _getSteps(startValue, endValue, minStep, formatter) {
+      const { min, max, stepInMs } = this.props;
+      // The timeStep should be big enough so that there won't be
+      // duplications. Ex: minimum step for minute should be 60000ms,
+      // if smaller than that, next step might return the same minute.
+      const timeStep = Math.max(minStep, stepInMs);
+
+      // Make sure the starting point and end point is not off step
+      let time = min.valueOf() + Math.ceil((startValue - min.valueOf()) / timeStep) * timeStep;
+      let maxValue = min.valueOf() + Math.floor((max.valueOf() - min.valueOf()) / stepInMs) * stepInMs;
+      let steps = [];
+
+      // Increment by timeStep until reaching the end of the range.
+      while (time <= endValue) {
+        steps.push({
+          value: formatter(time),
+          // Check if the value is within the min and max. If it's out of range,
+          // also check for the case when minStep is too large, and has stepped out
+          // of range when it should be enabled.
+          enabled: (time >= min.valueOf() && time <= max.valueOf()) ||
+            (time > maxValue && startValue <= maxValue &&
+              endValue >= maxValue && formatter(time) == formatter(maxValue))
+        });
+        time += timeStep;
+      }
+
+      return steps;
+    },
+
+    /**
+     * A generic function for stepping up or down from a value of a range.
+     * It stops at the upper and lower limits.
+     *
+     * @param  {Number} current: The current value
+     * @param  {Number} offset: The offset relative to current value
+     * @param  {Array<Object>} range: List of possible steps
+     * @return {Number} The new value
+     */
+    _step(current, offset, range) {
+      const index = range.findIndex(step => step.value == current);
+      const newIndex = offset > 0 ?
+                     Math.min(index + offset, range.length - 1) :
+                     Math.max(index + offset, 0);
+      return range[newIndex].value;
+    },
+
+    /**
+     * Step up or down AM/PM
+     *
+     * @param  {Number} offset
+     */
+    stepDayPeriodBy(offset) {
+      const current = this.dayPeriod;
+      const dayPeriod = this._step(current, offset, this.state.ranges.dayPeriod);
+
+      if (current != dayPeriod) {
+        this.hour < DAY_PERIOD_IN_HOURS ?
+          this.setState({ hour: this.hour + DAY_PERIOD_IN_HOURS }) :
+          this.setState({ hour: this.hour - DAY_PERIOD_IN_HOURS });
+      }
+    },
+
+    /**
+     * Step up or down hours
+     *
+     * @param  {Number} offset
+     */
+    stepHourBy(offset) {
+      const current = this.hour;
+      const hour = this._step(current, offset, this.state.ranges.hours);
+
+      if (current != hour) {
+        this.setState({ hour });
+      }
+    },
+
+    /**
+     * Step up or down minutes
+     *
+     * @param  {Number} offset
+     */
+    stepMinuteBy(offset) {
+      const current = this.minute;
+      const minute = this._step(current, offset, this.state.ranges.minutes);
+
+      if (current != minute) {
+        this.setState({ minute });
+      }
+    },
+
+    /**
+     * Step up or down seconds
+     *
+     * @param  {Number} offset
+     */
+    stepSecondBy(offset) {
+      const current = this.second;
+      const second = this._step(current, offset, this.state.ranges.seconds);
+
+      if (current != second) {
+        this.setState({ second });
+      }
+    },
+
+    /**
+     * Step up or down milliseconds
+     *
+     * @param  {Number} offset
+     */
+    stepMillisecondBy(offset) {
+      const current = this.milliseconds;
+      const millisecond = this._step(current, offset, this.state.ranges.millisecond);
+
+      if (current != millisecond) {
+        this.setState({ millisecond });
+      }
+    },
+
+    /**
+     * Checks if the time state is off step.
+     *
+     * @param  {Date} time
+     * @return {Boolean}
+     */
+    _isOffStep(time) {
+      const { min, stepInMs } = this.props;
+
+      return (time.valueOf() - min.valueOf()) % stepInMs != 0;
+    }
+  };
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/content/widgets/timepicker.js
@@ -0,0 +1,249 @@
+/* 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';
+
+function TimePicker(context) {
+  this.context = context;
+  this._attachEventListeners();
+};
+
+{
+  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 = {
+    /**
+     * Initializes the time picker. Set the default states and properties.
+     * @param  {Object} props
+     *         {
+     *           {Number} hour [optional]: Hour in 24 hours format (0~23), default is current hour
+     *           {Number} minute [optional]: Minute (0~59), default is current minute
+     *           {String} min [optional]: Minimum time, in 24 hours format. ex: "05:45"
+     *           {String} max [optional]: Maximum time, in 24 hours format. ex: "23:00"
+     *           {Number} step [optional]: Step size in minutes. Default is 60.
+     *           {String} format [optional]: "12" for 12 hours, "24" for 24 hours format
+     *           {String} locale [optional]: User preferred locale
+     *         }
+     */
+    init(props) {
+      this.props = props || {};
+      this._setDefaultState();
+      this._createComponents();
+      this._setComponentStates();
+    },
+
+    /*
+     * Set initial time states. If there's no hour & minute, it will
+     * use the current time. The Time module keeps track of the time states,
+     * and calculates the valid options given the time, min, max, step,
+     * and format (12 or 24).
+     */
+    _setDefaultState() {
+      const { hour, minute, min, max, step, format } = this.props;
+      const now = new Date();
+
+      let timerHour = hour == undefined ? now.getHours() : hour;
+      let timerMinute = minute == undefined ? now.getMinutes() : minute;
+
+      // The spec defines 1 step == 1 second, need to convert to ms for timekeeper
+      let timeKeeper = new TimeKeeper({
+        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.
+     */
+    _parseTimeString(timeString) {
+      let time = new Date("1970-01-01T" + timeString + "Z");
+      return time.toString() == "Invalid Date" ? false : time;
+    },
+
+    /**
+     * Initalize the spinner components.
+     */
+    _createComponents() {
+      const { locale, step, format } = this.props;
+      const { timeKeeper } = this.state;
+
+      const wrapSetValueFn = (setTimeFunction) => {
+        return (value) => {
+          setTimeFunction(value);
+          this._setComponentStates();
+          this._dispatchState();
+        };
+      };
+      const numberFormat = new Intl.NumberFormat(locale).format;
+
+      this.components = {
+        hour: new Spinner({
+          setValue: wrapSetValueFn(value => {
+            timeKeeper.setHour(value);
+            this.state.isHourSet = true;
+          }),
+          getDisplayString: hour => {
+            if (format == "24") {
+              return numberFormat(hour);
+            } else {
+              // Hour 0 in 12 hour format is displayed as 12.
+              const hourIn12 = hour % DAY_PERIOD_IN_HOURS;
+              return hourIn12 == 0 ? numberFormat(12)
+                : numberFormat(hourIn12);
+            }
+          }
+        }, this.context),
+        minute: new Spinner({
+          setValue: wrapSetValueFn(value => {
+            timeKeeper.setMinute(value);
+            this.state.isMinuteSet = true;
+          }),
+          getDisplayString: minute => numberFormat(minute)
+        }, this.context)
+      };
+
+      // 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"
+        }, this.context);
+      }
+    },
+
+    /**
+     * 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;
+
+      this.components.hour.setState({
+        value: setToMinValue ? timeKeeper.ranges.hours[0].value : timeKeeper.hour,
+        items: timeKeeper.ranges.hours,
+        isInfiniteScroll: true,
+        isValueSet: isHourSet,
+        isInvalid
+      });
+
+      this.components.minute.setState({
+        value: setToMinValue ? timeKeeper.ranges.minutes[0].value : timeKeeper.minute,
+        items: timeKeeper.ranges.minutes,
+        isInfiniteScroll: true,
+        isValueSet: isMinuteSet,
+        isInvalid
+      });
+
+      // The AM/PM spinner is only available in 12hr mode
+      if (this.props.format == "12") {
+        this.components.dayPeriod.setState({
+          value: setToMinValue ? timeKeeper.ranges.dayPeriod[0].value : timeKeeper.dayPeriod,
+          items: timeKeeper.ranges.dayPeriod,
+          isInfiniteScroll: false,
+          isValueSet: isDayPeriodSet,
+          isInvalid
+        });
+      }
+    },
+
+    /**
+     * Dispatch CustomEvent to pass the state of picker to the panel.
+     */
+    _dispatchState() {
+      const { hour, minute } = this.state.timeKeeper;
+      const { isHourSet, isMinuteSet, isDayPeriodSet } = this.state;
+      // The panel is listening to window for postMessage event, so we
+      // do postMessage to itself to send data to input boxes.
+      window.postMessage({
+        name: "TimePickerPopupChanged",
+        detail: {
+          hour,
+          minute,
+          isHourSet,
+          isMinuteSet,
+          isDayPeriodSet
+        }
+      }, "*");
+    },
+    _attachEventListeners() {
+      window.addEventListener('message', this);
+    },
+
+    /**
+     * Handle events.
+     *
+     * @param  {Event} event
+     */
+    handleEvent(event) {
+      switch (event.type) {
+        case "message": {
+          this.handleMessage(event);
+          break;
+        }
+      }
+    },
+
+    /**
+     * Handle postMessage events.
+     *
+     * @param {Event} event
+     */
+    handleMessage(event) {
+      switch (event.data.name) {
+        case "TimePickerSetValue": {
+          this.set(event.data.detail);
+          break;
+        }
+        case "TimePickerInit": {
+          this.init(event.data.detail);
+          break;
+        }
+      }
+    },
+
+    /**
+     * Set the time state and update the components with the new state.
+     *
+     * @param {Object} timeState
+     *        {
+     *          {Number} hour [optional]
+     *          {Number} minute [optional]
+     *          {Number} second [optional]
+     *          {Number} millisecond [optional]
+     *        }
+     */
+    set(timeState) {
+      if (timeState.hour != undefined) {
+        this.state.isHourSet = true;
+      }
+      if (timeState.minute != undefined) {
+        this.state.isMinuteSet = true;
+      }
+      this.state.timeKeeper.setState(timeState);
+      this._setComponentStates();
+    }
+  };
+}
--- a/toolkit/modules/DateTimePickerHelper.jsm
+++ b/toolkit/modules/DateTimePickerHelper.jsm
@@ -57,23 +57,21 @@ this.DateTimePickerHelper = {
       case "FormDateTime:OpenPicker": {
         this.showPicker(aMessage.target, aMessage.data);
         break;
       }
       case "FormDateTime:ClosePicker": {
         if (!this.picker) {
           return;
         }
-        this.picker.hidePopup();
+        this.picker.closePicker();
         break;
       }
       case "FormDateTime:UpdatePicker": {
-        let value = aMessage.data.value;
-        debug("Input box value is now: " + value.hour + ":" + value.minute);
-        // TODO: updating picker will be handled in Bug 1283384.
+        this.picker.setPopupValue(aMessage.data);
         break;
       }
       default:
         break;
     }
   },
 
   // nsIDOMEventListener
@@ -110,16 +108,24 @@ this.DateTimePickerHelper = {
   },
 
   // Get picker from browser and show it anchored to the input box.
   showPicker: function(aBrowser, aData) {
     let rect = aData.rect;
     let dir = aData.dir;
     let type = aData.type;
     let detail = aData.detail;
+
+    this._anchor = aBrowser.ownerGlobal.gBrowser.popupAnchor;
+    this._anchor.left = rect.left;
+    this._anchor.top = rect.top;
+    this._anchor.width = rect.width;
+    this._anchor.height = rect.height;
+    this._anchor.hidden = false;
+
     debug("Opening picker with details: " + JSON.stringify(detail));
 
     let window = aBrowser.ownerDocument.defaultView;
     let tabbrowser = window.gBrowser;
     if (Services.focus.activeWindow != window ||
         tabbrowser.selectedBrowser != aBrowser) {
       // We were sent a message from a window or tab that went into the
       // background, so we'll ignore it for now.
@@ -127,27 +133,30 @@ this.DateTimePickerHelper = {
     }
 
     this.weakBrowser = Cu.getWeakReference(aBrowser);
     this.picker = aBrowser.dateTimePicker;
     if (!this.picker) {
       debug("aBrowser.dateTimePicker not found, exiting now.");
       return;
     }
-    this.picker.hidden = false;
-    this.picker.openPopupAtScreenRect("after_start", rect.left, rect.top,
-                                      rect.width, rect.height, false, false);
+    this.picker.loadPicker(type, detail);
+    // The arrow panel needs an anchor to work. The popupAnchor (this._anchor)
+    // is a transparent div that the arrow can point to.
+    this.picker.openPopup(this._anchor, "after_start", rect.left, rect.top);
+
     this.addPickerListeners();
   },
 
   // Picker is closed, do some cleanup.
   close: function() {
     this.removePickerListeners();
     this.picker = null;
     this.weakBrowser = null;
+    this._anchor.hidden = true;
   },
 
   // Listen to picker's event.
   addPickerListeners: function() {
     if (!this.picker) {
       return;
     }
     this.picker.addEventListener("popuphidden", this);
--- a/toolkit/themes/shared/jar.inc.mn
+++ b/toolkit/themes/shared/jar.inc.mn
@@ -16,16 +16,17 @@ toolkit.jar:
   skin/classic/global/aboutCacheEntry.css                  (../../shared/aboutCacheEntry.css)
   skin/classic/global/aboutMemory.css                      (../../shared/aboutMemory.css)
   skin/classic/global/aboutReader.css                      (../../shared/aboutReader.css)
   skin/classic/global/aboutReaderContent.css               (../../shared/aboutReaderContent.css)
 * skin/classic/global/aboutReaderControls.css              (../../shared/aboutReaderControls.css)
   skin/classic/global/aboutSupport.css                     (../../shared/aboutSupport.css)
   skin/classic/global/appPicker.css                        (../../shared/appPicker.css)
   skin/classic/global/config.css                           (../../shared/config.css)
+  skin/classic/global/timepicker.css                       (../../shared/timepicker.css)
   skin/classic/global/icons/find-arrows.svg                (../../shared/icons/find-arrows.svg)
   skin/classic/global/icons/info.svg                       (../../shared/incontent-icons/info.svg)
   skin/classic/global/icons/input-clear.svg                (../../shared/icons/input-clear.svg)
   skin/classic/global/icons/loading.png                    (../../shared/icons/loading.png)
   skin/classic/global/icons/loading@2x.png                 (../../shared/icons/loading@2x.png)
   skin/classic/global/icons/warning.svg                    (../../shared/incontent-icons/warning.svg)
   skin/classic/global/icons/blocked.svg                    (../../shared/incontent-icons/blocked.svg)
   skin/classic/global/alerts/alert-common.css              (../../shared/alert-common.css)
new file mode 100644
--- /dev/null
+++ b/toolkit/themes/shared/timepicker.css
@@ -0,0 +1,88 @@
+/* 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;
+}
+
+body {
+  margin: 0;
+  font-size: var(--font-size);
+}
+
+#time-picker {
+  display: flex;
+  flex-direction: row;
+}
+
+.spinner-container {
+  font-family: sans-serif;
+  display: flex;
+  flex-direction: column;
+  width: var(--spinner-width);
+}
+
+.spinner-container button {
+  -moz-appearance: none;
+  border: none;
+  background: none;
+  height: var(--spinner-item-height);
+}
+
+.spinner-container .stack {
+  position: relative;
+  height: var(--spinner-height);
+}
+
+.spinner-container .spinner {
+  position: absolute;
+  height: var(--spinner-height);
+  width: 100%;
+  cursor: default;
+  overflow-y: scroll;
+  scroll-snap-type: mandatory;
+  scroll-snap-points-y: repeat(100%);
+}
+
+.spinner-container .spinner > div {
+  position: relative;
+  text-align: center;
+  padding: calc(var(--spinner-item-height) / 4) 0;
+  height: calc(var(--spinner-item-height) / 2);
+  -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 {
+  content: "";
+  background: var(--selected-bgcolor);
+  position: absolute;
+  top: 5%;
+  bottom: 5%;
+  left: 10%;
+  right: 10%;
+  border-radius: 5%;
+  z-index: -10;
+}
+
+.spinner-container .spinner > div.disabled {
+  color: var(--disabled-color);
+}