Bug 1283385 - (1/2) Implement date picker UI; r?mconley draft
authorScott Wu <scottcwwu@gmail.com>
Wed, 09 Nov 2016 23:38:11 +0800
changeset 449528 e214ff183d5eecfacb33753ba0145247cfc8bf73
parent 448717 f46f85dcfbc2b3098ea758825d18be6fab33cbc6
child 449529 375d080150e267451d5b85e41d4c90ec3be99752
push id38592
push userbmo:scwwu@mozilla.com
push dateWed, 14 Dec 2016 09:16:58 +0000
reviewersmconley
bugs1283385
milestone53.0a1
Bug 1283385 - (1/2) Implement date picker UI; r?mconley MozReview-Commit-ID: 8uscU75qrkR
toolkit/content/datepicker.xhtml
toolkit/content/jar.mn
toolkit/content/widgets/calendar.js
toolkit/content/widgets/datekeeper.js
toolkit/content/widgets/datepicker.js
toolkit/content/widgets/spinner.js
toolkit/themes/shared/timepicker.css
new file mode 100644
--- /dev/null
+++ b/toolkit/content/datepicker.xhtml
@@ -0,0 +1,61 @@
+<?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>Date Picker</title>
+  <link rel="stylesheet" href="chrome://global/skin/timepicker.css"/>
+  <script type="application/javascript" src="chrome://global/content/bindings/datekeeper.js"></script>
+  <script type="application/javascript" src="chrome://global/content/bindings/spinner.js"></script>
+  <script type="application/javascript" src="chrome://global/content/bindings/calendar.js"></script>
+  <script type="application/javascript" src="chrome://global/content/bindings/datepicker.js"></script>
+</head>
+<body>
+  <div id="date-picker">
+    <div class="calendar-container">
+      <div class="nav">
+        <button class="left">&lt;</button>
+        <button class="right">&gt;</button>
+      </div>
+      <div class="week-header"></div>
+      <div class="days-viewport">
+        <div class="days-view"></div>
+      </div>
+    </div>
+    <div class="month-year">
+      <div class="month-year-label"></div>
+      <div class="month-year-arrow"></div>
+    </div>
+    <div class="month-year-view"></div>
+  </div>
+  <template id="spinner-template">
+    <div class="spinner-container">
+      <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).
+                  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 DatePicker instance and prepare to be
+  // initialized by the "DatePickerInit" event from datetimepopup.xml
+  const root = document.getElementById("date-picker");
+  new DatePicker({
+    monthYear: root.querySelector(".month-year"),
+    monthYearView: root.querySelector(".month-year-view"),
+    buttonLeft: root.querySelector(".left"),
+    buttonRight: root.querySelector(".right"),
+    weekHeader: root.querySelector(".week-header"),
+    daysView: root.querySelector(".days-view")
+  });
+  </script>
+</body>
+</html>
\ No newline at end of file
--- a/toolkit/content/jar.mn
+++ b/toolkit/content/jar.mn
@@ -40,16 +40,17 @@ toolkit.jar:
    content/global/browser-content.js
 *   content/global/buildconfig.html
    content/global/contentAreaUtils.js
 #ifndef MOZ_FENNEC
    content/global/customizeToolbar.css
    content/global/customizeToolbar.js
    content/global/customizeToolbar.xul
 #endif
+   content/global/datepicker.xhtml
    content/global/devicestorage.properties
 #ifndef MOZ_FENNEC
    content/global/editMenuOverlay.js
 *  content/global/editMenuOverlay.xul
    content/global/finddialog.js
 *  content/global/finddialog.xul
    content/global/findUtils.js
 #endif
@@ -64,18 +65,21 @@ toolkit.jar:
    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/calendar.js         (widgets/calendar.js)
    content/global/bindings/checkbox.xml        (widgets/checkbox.xml)
    content/global/bindings/colorpicker.xml     (widgets/colorpicker.xml)
+   content/global/bindings/datekeeper.js       (widgets/datekeeper.js)
+   content/global/bindings/datepicker.js       (widgets/datepicker.js)
    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)
new file mode 100644
--- /dev/null
+++ b/toolkit/content/widgets/calendar.js
@@ -0,0 +1,172 @@
+/* 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";
+
+/**
+ * Initialize the Calendar and generate nodes for week headers and days, and
+ * attach event listeners.
+ *
+ * @param {Object} options
+ *        {
+ *          {Number} calViewSize: Number of days to appear on a calendar view
+ *        }
+ * @param {Object} context
+ *        {
+ *          {DOMElement} weekHeader
+ *          {DOMElement} daysView
+ *        }
+ */
+function Calendar(options, context) {
+  const DAYS_IN_A_WEEK = 7;
+
+  this.context = context;
+  this.state = {
+    days: [],
+    weekHeaders: []
+  };
+  this.props = {};
+  this.elements = {
+    weekHeaders: this._generateNodes(DAYS_IN_A_WEEK, context.weekHeader),
+    daysView: this._generateNodes(options.calViewSize, context.daysView)
+  };
+
+  this._attachEventListeners();
+}
+
+{
+  Calendar.prototype = {
+
+    /**
+     * Set new properties and render them.
+     *
+     * @param {Object} props
+     *        {
+     *          {Boolean} isVisible: Whether or not the calendar is in view
+     *          {Array<Object>} days: Data for days
+     *          {
+     *            {Number} dateValue: Date in milliseconds
+     *            {Number} textContent
+     *            {Array<String>} classNames
+     *          }
+     *          {Array<Object>} weekHeaders: Data for weekHeaders
+     *          {
+     *            {Number} textContent
+     *            {Array<String>} classNames
+     *          }
+     *          {Function} getDayString: Transform day number to string
+     *          {Function} getWeekHeaderString: Transform day of week number to string
+     *          {Function} setValue: Set value for dateKeeper
+     *          {Number} selectionValue: The selection date value
+     *        }
+     */
+    setProps(props) {
+      if (props.isVisible) {
+        // Transform the days and weekHeaders array for rendering
+        const days = props.days.map(({ dateValue, textContent, classNames }) => {
+          return {
+            dateValue,
+            textContent: props.getDayString(textContent),
+            className: dateValue == props.selectionValue ?
+                       classNames.concat("selection").join(" ") :
+                       classNames.join(" ")
+          };
+        });
+        const weekHeaders = props.weekHeaders.map(({ textContent, classNames }) => {
+          return {
+            textContent: props.getWeekHeaderString(textContent),
+            className: classNames.join(" ")
+          };
+        });
+        // Update the DOM nodes states
+        this._render({
+          elements: this.elements.daysView,
+          items: days,
+          prevState: this.state.days
+        });
+        this._render({
+          elements: this.elements.weekHeaders,
+          items: weekHeaders,
+          prevState: this.state.weekHeaders,
+        });
+        // Update the state to current
+        this.state.days = days;
+        this.state.weekHeaders = weekHeaders;
+      }
+
+      this.props = Object.assign(this.props, props);
+    },
+
+    /**
+     * Render the items onto the DOM nodes
+     * @param  {Object}
+     *         {
+     *           {Array<DOMElement>} elements
+     *           {Array<Object>} items
+     *           {Array<Object>} prevState: state of items from last render
+     *         }
+     */
+    _render({ elements, items, prevState }) {
+      for (let i = 0, l = items.length; i < l; i++) {
+        let el = elements[i];
+
+        // Check if state from last render has changed, if so, update the elements
+        if (!prevState[i] || prevState[i].textContent != items[i].textContent) {
+          el.textContent = items[i].textContent;
+        }
+        if (!prevState[i] || prevState[i].className != items[i].className) {
+          el.className = items[i].className;
+        }
+      }
+    },
+
+    /**
+     * Generate DOM nodes
+     *
+     * @param  {Number} size: Number of nodes to generate
+     * @param  {DOMElement} context: Element to append the nodes to
+     * @return {Array<DOMElement>}
+     */
+    _generateNodes(size, context) {
+      let frag = document.createDocumentFragment();
+      let refs = [];
+
+      for (let i = 0; i < size; i++) {
+        let el = document.createElement("div");
+        el.dataset.id = i;
+        refs.push(el);
+        frag.appendChild(el);
+      }
+      context.appendChild(frag);
+
+      return refs;
+    },
+
+    /**
+     * Handle events
+     * @param  {DOMEvent} event
+     */
+    handleEvent(event) {
+      switch (event.type) {
+        case "click": {
+          if (event.target.parentNode == this.context.daysView) {
+            let targetId = event.target.dataset.id;
+            this.props.setValue({
+              selectionValue: this.props.days[targetId].dateValue,
+              dateValue: this.props.days[targetId].dateValue
+            });
+          }
+          break;
+        }
+      }
+    },
+
+    /**
+     * Attach event listener to daysView
+     */
+    _attachEventListeners() {
+      this.context.daysView.addEventListener("click", this);
+    }
+  };
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/content/widgets/datekeeper.js
@@ -0,0 +1,244 @@
+/* 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";
+
+/**
+ * DateKeeper keeps track of the date states.
+ *
+ * @param {Object} date parts
+ *        {
+ *          {Number} year
+ *          {Number} month
+ *          {Number} date
+ *        }
+ *        {Object} options
+ *        {
+ *          {Number} firstDayOfWeek [optional]
+ *          {Array<Number>} weekends [optional]
+ *          {Number} calViewSize [optional]
+ *        }
+ */
+function DateKeeper({ year, month, date }, { firstDayOfWeek = 0, weekends = [0], calViewSize = 42 }) {
+  this.state = {
+    firstDayOfWeek, weekends, calViewSize,
+    dateObj: new Date(0),
+    years: [],
+    months: [],
+    days: []
+  };
+  this.state.weekHeaders = this._getWeekHeaders(firstDayOfWeek);
+  this._update(year, month, date);
+}
+
+{
+  const DAYS_IN_A_WEEK = 7,
+        MONTHS_IN_A_YEAR = 12,
+        YEAR_VIEW_SIZE = 200,
+        YEAR_BUFFER_SIZE = 10;
+
+  DateKeeper.prototype = {
+    /**
+     * Set new date
+     * @param {Object} date parts
+     *        {
+     *          {Number} year [optional]
+     *          {Number} month [optional]
+     *          {Number} date [optional]
+     *        }
+     */
+    set({ year = this.state.year, month = this.state.month, date = this.state.date }) {
+      this._update(year, month, date);
+    },
+
+    /**
+     * Set date with value
+     * @param {Number} value: Date value
+     */
+    setValue(value) {
+      const dateObj = new Date(value);
+      this._update(dateObj.getUTCFullYear(), dateObj.getUTCMonth(), dateObj.getUTCDate());
+    },
+
+    /**
+     * Set month. Makes sure the date is <= the last day of the month
+     * @param {Number} month
+     */
+    setMonth(month) {
+      const lastDayOfMonth = this._newUTCDate(this.state.year, month + 1, 0).getUTCDate();
+      this._update(this.state.year, month, Math.min(this.state.date, lastDayOfMonth));
+    },
+
+    /**
+     * Set year. Makes sure the date is <= the last day of the month
+     * @param {Number} year
+     */
+    setYear(year) {
+      const lastDayOfMonth = this._newUTCDate(year, this.state.month + 1, 0).getUTCDate();
+      this._update(year, this.state.month, Math.min(this.state.date, lastDayOfMonth));
+    },
+
+    /**
+     * Set month by offset. Makes sure the date is <= the last day of the month
+     * @param {Number} offset
+     */
+    setMonthByOffset(offset) {
+      const lastDayOfMonth = this._newUTCDate(this.state.year, this.state.month + offset + 1, 0).getUTCDate();
+      this._update(this.state.year, this.state.month + offset, Math.min(this.state.date, lastDayOfMonth));
+    },
+
+    /**
+     * Update the states.
+     * @param  {Number} year  [description]
+     * @param  {Number} month [description]
+     * @param  {Number} date  [description]
+     */
+    _update(year, month, date) {
+      // Use setUTCFullYear so that year 99 doesn't get parsed as 1999
+      this.state.dateObj.setUTCFullYear(year, month, date);
+      this.state.year = this.state.dateObj.getUTCFullYear();
+      this.state.month = this.state.dateObj.getUTCMonth();
+      this.state.date = this.state.dateObj.getUTCDate();
+    },
+
+    /**
+     * Generate the array of months
+     * @return {Array<Object>}
+     *         {
+     *           {Number} value: Month in int
+     *           {Boolean} enabled
+     *         }
+     */
+    getMonths() {
+      // TODO: add min/max and step support
+      let months = [];
+
+      for (let i = 0; i < MONTHS_IN_A_YEAR; i++) {
+        months.push({
+          value: i,
+          enabled: true
+        });
+      }
+
+      return months;
+    },
+
+    /**
+     * Generate the array of years
+     * @return {Array<Object>}
+     *         {
+     *           {Number} value: Year in int
+     *           {Boolean} enabled
+     *         }
+     */
+    getYears() {
+      // TODO: add min/max and step support
+      let years = [];
+
+      const firstItem = this.state.years[0];
+      const lastItem = this.state.years[this.state.years.length - 1];
+      const currentYear = this.state.dateObj.getUTCFullYear();
+
+      // Generate new years array when the year is outside of the first &
+      // last item range. If not, return the cached result.
+      if (!firstItem || !lastItem ||
+          currentYear <= firstItem.value + YEAR_BUFFER_SIZE ||
+          currentYear >= lastItem.value - YEAR_BUFFER_SIZE) {
+        // The year is set in the middle with items on both directions
+        for (let i = -(YEAR_VIEW_SIZE / 2); i < YEAR_VIEW_SIZE / 2; i++) {
+          years.push({
+            value: currentYear + i,
+            enabled: true
+          });
+        }
+        this.state.years = years;
+      }
+      return this.state.years;
+    },
+
+    /**
+     * Get days for calendar
+     * @return {Array<Object>}
+     *         {
+     *           {Number} dateValue
+     *           {Number} textContent
+     *           {Array<String>} classNames
+     *         }
+     */
+    getDays() {
+      // TODO: add min/max and step support
+      let firstDayOfMonth = this._getFirstCalendarDate(this.state.dateObj, this.state.firstDayOfWeek);
+      let days = [];
+      let month = this.state.dateObj.getUTCMonth();
+
+      for (let i = 0; i < this.state.calViewSize; i++) {
+        let dateObj = this._newUTCDate(firstDayOfMonth.getUTCFullYear(), firstDayOfMonth.getUTCMonth(), firstDayOfMonth.getUTCDate() + i);
+        let classNames = [];
+        if (this.state.weekends.includes(dateObj.getUTCDay())) {
+          classNames.push("weekend");
+        }
+        if (month != dateObj.getUTCMonth()) {
+          classNames.push("outside");
+        }
+        days.push({
+          dateValue: dateObj.getTime(),
+          textContent: dateObj.getUTCDate(),
+          classNames
+        });
+      }
+      return days;
+    },
+
+    /**
+     * Get week headers for calendar
+     * @param  {Number} firstDayOfWeek
+     * @return {Array<Object>}
+     *         {
+     *           {Number} textContent
+     *           {Array<String>} classNames
+     *         }
+     */
+    _getWeekHeaders(firstDayOfWeek) {
+      let headers = [];
+      let day = firstDayOfWeek;
+
+      for (let i = 0; i < DAYS_IN_A_WEEK; i++) {
+        headers.push({
+          textContent: day % DAYS_IN_A_WEEK,
+          classNames: this.state.weekends.includes(day % DAYS_IN_A_WEEK) ? ["weekend"] : []
+        });
+        day++;
+      }
+      return headers;
+    },
+
+    /**
+     * Get the first day on a calendar month
+     * @param  {Date} dateObj
+     * @param  {Number} firstDayOfWeek
+     * @return {Date}
+     */
+    _getFirstCalendarDate(dateObj, firstDayOfWeek) {
+      const daysOffset = 1 - DAYS_IN_A_WEEK;
+      let firstDayOfMonth = this._newUTCDate(dateObj.getUTCFullYear(), dateObj.getUTCMonth());
+      let dayOfWeek = firstDayOfMonth.getUTCDay();
+
+      return this._newUTCDate(
+        firstDayOfMonth.getUTCFullYear(),
+        firstDayOfMonth.getUTCMonth(),
+        // When first calendar date is the same as first day of the week, add
+        // another row on top of it.
+        firstDayOfWeek == dayOfWeek ? daysOffset : (firstDayOfWeek - dayOfWeek + daysOffset) % DAYS_IN_A_WEEK);
+    },
+
+    /**
+     * Helper function for creating UTC dates
+     * @param  {...[Number]} parts
+     * @return {Date}
+     */
+    _newUTCDate(...parts) {
+      return new Date(Date.UTC(...parts));
+    }
+  };
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/content/widgets/datepicker.js
@@ -0,0 +1,354 @@
+/* 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 DatePicker(context) {
+  this.context = context;
+  this._attachEventListeners();
+}
+
+{
+  const CAL_VIEW_SIZE = 42;
+
+  DatePicker.prototype = {
+    /**
+     * Initializes the date picker. Set the default states and properties.
+     * @param  {Object} props
+     *         {
+     *           {Number} year [optional]
+     *           {Number} month [optional]
+     *           {Number} date [optional]
+     *           {String} locale [optional]: User preferred locale
+     *         }
+     */
+    init(props = {}) {
+      this.props = props;
+      this._setDefaultState();
+      this._createComponents();
+      this._update();
+    },
+
+    /*
+     * Set initial date picker states.
+     */
+    _setDefaultState() {
+      const now = new Date();
+      const { year = now.getFullYear(),
+              month = now.getMonth(),
+              date = now.getDate(),
+              locale } = this.props;
+
+      // TODO: Use calendar info API to get first day of week & weekends
+      //       (Bug 1287503)
+      const dateKeeper = new DateKeeper({
+        year, month, date
+      }, {
+        calViewSize: CAL_VIEW_SIZE,
+        firstDayOfWeek: 0,
+        weekends: [0]
+      });
+
+      this.state = {
+        dateKeeper,
+        locale,
+        isMonthPickerVisible: false,
+        isYearSet: false,
+        isMonthSet: false,
+        isDateSet: false,
+        getDayString: new Intl.NumberFormat(locale).format,
+        // TODO: use calendar terms when available (Bug 1287677)
+        getWeekHeaderString: weekday => ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][weekday],
+        setValue: ({ dateValue, selectionValue }) => {
+          dateKeeper.setValue(dateValue);
+          this.state.selectionValue = selectionValue;
+          this.state.isYearSet = true;
+          this.state.isMonthSet = true;
+          this.state.isDateSet = true;
+          this._update();
+          this._dispatchState();
+        },
+        setYear: year => {
+          dateKeeper.setYear(year);
+          this.state.isYearSet = true;
+          this._update();
+          this._dispatchState();
+        },
+        setMonth: month => {
+          dateKeeper.setMonth(month);
+          this.state.isMonthSet = true;
+          this._update();
+          this._dispatchState();
+        },
+        toggleMonthPicker: () => {
+          this.state.isMonthPickerVisible = !this.state.isMonthPickerVisible;
+          this._update();
+        }
+      };
+    },
+
+    /**
+     * Initalize the date picker components.
+     */
+    _createComponents() {
+      this.components = {
+        calendar: new Calendar({
+          calViewSize: CAL_VIEW_SIZE,
+          locale: this.state.locale
+        }, {
+          weekHeader: this.context.weekHeader,
+          daysView: this.context.daysView
+        }),
+        monthYear: new MonthYear({
+          setYear: this.state.setYear,
+          setMonth: this.state.setMonth,
+          locale: this.state.locale
+        }, {
+          monthYear: this.context.monthYear,
+          monthYearView: this.context.monthYearView
+        })
+      };
+    },
+
+    /**
+     * Update date picker and its components.
+     */
+    _update() {
+      const { dateKeeper, selectionValue, isMonthPickerVisible } = this.state;
+
+      if (isMonthPickerVisible) {
+        this.state.months = dateKeeper.getMonths();
+        this.state.years = dateKeeper.getYears();
+      } else {
+        this.state.days = dateKeeper.getDays();
+      }
+
+      this.components.monthYear.setProps({
+        isVisible: isMonthPickerVisible,
+        dateObj: dateKeeper.state.dateObj,
+        month: dateKeeper.state.month,
+        months: this.state.months,
+        year: dateKeeper.state.year,
+        years: this.state.years,
+        toggleMonthPicker: this.state.toggleMonthPicker
+      });
+      this.components.calendar.setProps({
+        isVisible: !isMonthPickerVisible,
+        days: this.state.days,
+        weekHeaders: dateKeeper.state.weekHeaders,
+        setValue: this.state.setValue,
+        getDayString: this.state.getDayString,
+        getWeekHeaderString: this.state.getWeekHeaderString,
+        selectionValue
+      });
+
+      isMonthPickerVisible ?
+        this.context.monthYearView.classList.remove("hidden") :
+        this.context.monthYearView.classList.add("hidden");
+    },
+
+    /**
+     * Use postMessage to pass the state of picker to the panel.
+     */
+    _dispatchState() {
+      const { year, month, date } = this.state.dateKeeper.state;
+      const { isYearSet, isMonthSet, isDateSet } = 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: "DatePickerPopupChanged",
+        detail: {
+          year,
+          month,
+          date,
+          isYearSet,
+          isMonthSet,
+          isDateSet
+        }
+      }, "*");
+    },
+
+    /**
+     * Attach event listeners
+     */
+    _attachEventListeners() {
+      window.addEventListener("message", this);
+      document.addEventListener("click", this);
+    },
+
+    /**
+     * Handle events.
+     *
+     * @param  {Event} event
+     */
+    handleEvent(event) {
+      switch (event.type) {
+        case "message": {
+          this.handleMessage(event);
+          break;
+        }
+        case "click": {
+          if (event.target == this.context.buttonLeft) {
+            this.state.dateKeeper.setMonthByOffset(-1);
+            this._update();
+          } else if (event.target == this.context.buttonRight) {
+            this.state.dateKeeper.setMonthByOffset(1);
+            this._update();
+          }
+          break;
+        }
+      }
+    },
+
+    /**
+     * Handle postMessage events.
+     *
+     * @param {Event} event
+     */
+    handleMessage(event) {
+      switch (event.data.name) {
+        case "DatePickerSetValue": {
+          this.set(event.data.detail);
+          break;
+        }
+        case "DatePickerInit": {
+          this.init(event.data.detail);
+          break;
+        }
+      }
+    },
+
+    /**
+     * Set the date state and update the components with the new state.
+     *
+     * @param {Object} dateState
+     *        {
+     *          {Number} year [optional]
+     *          {Number} month [optional]
+     *          {Number} date [optional]
+     *        }
+     */
+    set(dateState) {
+      if (dateState.year != undefined) {
+        this.state.isYearSet = true;
+      }
+      if (dateState.month != undefined) {
+        this.state.isMonthSet = true;
+      }
+      if (dateState.date != undefined) {
+        this.state.isDateSet = true;
+      }
+
+      this.state.dateKeeper.set(dateState);
+      this._update();
+    }
+  };
+
+  /**
+   * MonthYear is a component that handles the month & year spinners
+   *
+   * @param {Object} options
+   *        {
+   *          {String} locale
+   *          {Function} setYear
+   *          {Function} setMonth
+   *        }
+   * @param {DOMElement} context
+   */
+  function MonthYear(options, context) {
+    const spinnerSize = 5;
+    const monthFormat = new Intl.DateTimeFormat(options.locale, { month: "short" }).format;
+    const yearFormat = new Intl.DateTimeFormat(options.locale, { year: "numeric" }).format;
+    const dateFormat = new Intl.DateTimeFormat(options.locale, { year: "numeric", month: "long" }).format;
+
+    this.context = context;
+    this.state = { dateFormat };
+    this.props = {};
+    this.components = {
+      month: new Spinner({
+        setValue: month => {
+          this.state.isMonthSet = true;
+          options.setMonth(month);
+        },
+        getDisplayString: month => monthFormat(new Date(0, month)),
+        viewportSize: spinnerSize
+      }, context.monthYearView),
+      year: new Spinner({
+        setValue: year => {
+          this.state.isYearSet = true;
+          options.setYear(year);
+        },
+        getDisplayString: year => yearFormat(new Date(new Date(0).setFullYear(year))),
+        viewportSize: spinnerSize
+      }, context.monthYearView)
+    };
+
+    this._attachEventListeners();
+  }
+
+  MonthYear.prototype = {
+
+    /**
+     * Set new properties and pass them to components
+     *
+     * @param {Object} props
+     *        {
+     *          {Boolean} isVisible
+     *          {Date} dateObj
+     *          {Number} month
+     *          {Number} year
+     *          {Array<Object>} months
+     *          {Array<Object>} years
+     *          {Function} toggleMonthPicker
+     *         }
+     */
+    setProps(props) {
+      this.context.monthYear.textContent = this.state.dateFormat(props.dateObj);
+
+      if (props.isVisible) {
+        this.components.month.setState({
+          value: props.month,
+          items: props.months,
+          isInfiniteScroll: true,
+          isValueSet: this.state.isMonthSet,
+          smoothScroll: !this.state.firstOpened
+        });
+        this.components.year.setState({
+          value: props.year,
+          items: props.years,
+          isInfiniteScroll: false,
+          isValueSet: this.state.isYearSet,
+          smoothScroll: !this.state.firstOpened
+        });
+        this.state.firstOpened = false;
+      } else {
+        this.state.isMonthSet = false;
+        this.state.isYearSet = false;
+        this.state.firstOpened = true;
+      }
+
+      this.props = Object.assign(this.props, props);
+    },
+
+    /**
+     * Handle events
+     * @param  {DOMEvent} event
+     */
+    handleEvent(event) {
+      switch (event.type) {
+        case "click": {
+          this.props.toggleMonthPicker();
+          break;
+        }
+      }
+    },
+
+    /**
+     * Attach event listener to monthYear button
+     */
+    _attachEventListeners() {
+      this.context.monthYear.addEventListener("click", this);
+    }
+  };
+}
--- a/toolkit/content/widgets/spinner.js
+++ b/toolkit/content/widgets/spinner.js
@@ -93,33 +93,35 @@ function Spinner(props, context) {
      *          {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;
+      const { value: newValue, items: newItems, isValueSet, isInvalid, smoothScroll = true } = 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 (smoothScroll) {
+          this._smoothScrollTo(newValue, true);
+        } else {
+          this._scrollTo(newValue, true);
+        }
       }
 
-      if (isValueSet) {
-        if (isInvalid) {
-          this._removeSelection();
-        } else {
-          this._updateSelection();
-        }
+      if (isValueSet && !isInvalid) {
+        this._updateSelection();
+      } else {
+        this._removeSelection();
       }
     },
 
     /**
      * Whenever scroll event is detected:
      * - Update the index state
      * - If a smooth scroll has reached its destination, set [isScrolling] state
      *   to false
--- a/toolkit/themes/shared/timepicker.css
+++ b/toolkit/themes/shared/timepicker.css
@@ -6,51 +6,186 @@
   --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;
+  --calendar-width: 23.1rem;
+  --date-picker-item-height: 2.4rem;
 
   --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;
 
+  --weekday-font-color: #6C6C6C;
+  --weekday-outside-font-color: #6C6C6C;
+  --weekend-font-color: #DA4E44;
+  --weekend-outside-font-color: #FF988F;
+
   --disabled-opacity: 0.2;
 }
 
 html {
   font-size: 10px;
 }
 
 body {
   margin: 0;
   color: var(--font-color);
+  font: message-box;
   font-size: var(--font-size-default);
 }
 
-#time-picker {
+.nav {
+  display: flex;
+  width: var(--calendar-width);
+  height: 2.4rem;
+  margin-bottom: 0.8rem;
+  justify-content: space-between;
+}
+
+.nav button {
+  -moz-appearance: none;
+  background: none;
+  border: none;
+  width: 3rem;
+  height: var(--date-picker-item-height);
+}
+
+.month-year {
+  position: absolute;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  top: 0;
+  left: 3rem;
+  width: 17.1rem;
+  height: var(--date-picker-item-height);
+  z-index: 10;
+}
+
+.month-year-view {
+  position: absolute;
+  z-index: 5;
+  padding-top: 3.2rem;
+  top: 0;
+  left: 0;
+  bottom: 0;
+  width: var(--calendar-width);
+  background: window;
+  opacity: 1;
+  transition: opacity 0.15s;
+}
+
+.month-year-view.hidden {
+  visibility: hidden;
+  opacity: 0;
+}
+
+.month-year-view > .spinner-container {
+  width: 5.5rem;
+  margin: 0 0.5rem;
+}
+
+.month-year-view .spinner {
+  transform: scaleY(1);
+  transform-origin: top;
+  transition: transform 0.15s;
+}
+
+.month-year-view.hidden .spinner {
+  transform: scaleY(0);
+  transition: none;
+}
+
+.month-year-view .spinner > div {
+  transform: scaleY(1);
+  transition: transform 0.15s;
+}
+
+.month-year-view.hidden .spinner > div {
+  transform: scaleY(2.5);
+  transition: none;
+}
+
+.calendar-container {
+  cursor: default;
+  display: flex;
+  flex-direction: column;
+  width: var(--calendar-width);
+}
+
+.week-header {
+  display: flex;
+}
+
+.week-header > div {
+  color: var(--weekday-font-color);
+}
+
+.week-header > div.weekend {
+  color: var(--weekend-font-color);
+}
+
+.days-viewport {
+  height: 15rem;
+  overflow: hidden;
+  position: relative;
+}
+
+.days-view {
+  position: absolute;
+  display: flex;
+  flex-wrap: wrap;
+  flex-direction: row;
+}
+
+.week-header > div,
+.days-view > div {
+  align-items: center;
+  display: flex;
+  height: var(--date-picker-item-height);
+  margin: 0.05rem 0.15rem;
+  position: relative;
+  justify-content: center;
+  width: 3rem;
+}
+
+.days-view > div.outside {
+  color: var(--weekday-outside-font-color);
+}
+
+.days-view > div.weekend {
+  color: var(--weekend-font-color);
+}
+
+.days-view > div.weekend.outside {
+  color: var(--weekend-outside-font-color);
+}
+
+#time-picker,
+.month-year-view {
   display: flex;
   flex-direction: row;
-  justify-content: space-around;
+  justify-content: center;
 }
 
 .spinner-container {
-  font-family: sans-serif;
   display: flex;
   flex-direction: column;
   width: var(--spinner-width);
 }
 
 .spinner-container > button {
   -moz-appearance: none;
   border: none;
@@ -96,34 +231,37 @@ body {
   text-align: center;
   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:hover::before {
+.spinner-container > .spinner > div:hover::before,
+.calendar-container .days-view > div:hover::before {
   background: var(--fill-color);
   border: var(--border);
   border-radius: var(--border-radius);
   content: "";
   position: absolute;
   top: 0%;
   bottom: 0%;
   left: 0%;
   right: 0%;
   z-index: -10;
 }
 
-.spinner-container > .spinner:not(.scrolling) > div.selection {
+.spinner-container > .spinner:not(.scrolling) > div.selection,
+.calendar-container .days-view > div.selection {
   color: var(--selected-font-color);
 }
 
-.spinner-container > .spinner > div.selection::before {
+.spinner-container > .spinner > div.selection::before,
+.calendar-container .days-view > div.selection::before {
   background: var(--selected-fill-color);
   border: none;
   border-radius: var(--border-radius);
   content: "";
   position: absolute;
   top: 0%;
   bottom: 0%;
   left: 0%;