diff --git a/browser/base/content/browser.css b/browser/base/content/browser.css
index 58994632ab62..b06439bcfa92 100644
--- a/browser/base/content/browser.css
+++ b/browser/base/content/browser.css
@@ -520,6 +520,10 @@ toolbar:not(#TabsToolbar) > #personal-bookmarks {
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,
diff --git a/browser/base/content/browser.xul b/browser/base/content/browser.xul
index 687d5fc6119d..3473c4f1e58a 100644
--- a/browser/base/content/browser.xul
+++ b/browser/base/content/browser.xul
@@ -155,10 +155,14 @@
level="parent"/>
+ level="parent">
+
+
+
+
diff --git a/toolkit/content/widgets/spinner.js b/toolkit/content/widgets/spinner.js
new file mode 100644
index 000000000000..7d1c4a405d6a
--- /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} items
+ * [{
+ * {Number/String} value: The value in its original form
+ * {Boolean} enabled: Whether or not the item is enabled
+ * }]
+ * @param {Array} 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} a
+ * @param {Array} 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;
+ }
+ };
+}
diff --git a/toolkit/content/widgets/timekeeper.js b/toolkit/content/widgets/timekeeper.js
new file mode 100644
index 000000000000..1e74a57b950f
--- /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} dayPeriod
+ * {Array} hours
+ * {Array} minutes
+ * {Array} seconds
+ * {Array} 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}
+ */
+ _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}
+ */
+ _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}
+ */
+ _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}
+ */
+ _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}
+ */
+ _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}
+ * {
+ * {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} 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;
+ }
+ };
+}
diff --git a/toolkit/content/widgets/timepicker.js b/toolkit/content/widgets/timepicker.js
new file mode 100644
index 000000000000..a917f8b7281a
--- /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();
+ }
+ };
+}
diff --git a/toolkit/modules/DateTimePickerHelper.jsm b/toolkit/modules/DateTimePickerHelper.jsm
index 6dbaa9cc001c..398687988667 100644
--- a/toolkit/modules/DateTimePickerHelper.jsm
+++ b/toolkit/modules/DateTimePickerHelper.jsm
@@ -62,13 +62,11 @@ this.DateTimePickerHelper = {
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:
@@ -115,6 +113,14 @@ this.DateTimePickerHelper = {
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;
@@ -132,9 +138,11 @@ this.DateTimePickerHelper = {
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();
},
@@ -143,6 +151,7 @@ this.DateTimePickerHelper = {
this.removePickerListeners();
this.picker = null;
this.weakBrowser = null;
+ this._anchor.hidden = true;
},
// Listen to picker's event.
diff --git a/toolkit/themes/shared/jar.inc.mn b/toolkit/themes/shared/jar.inc.mn
index 2b6669bd00a4..9c3d86a40d3c 100644
--- a/toolkit/themes/shared/jar.inc.mn
+++ b/toolkit/themes/shared/jar.inc.mn
@@ -21,6 +21,7 @@ toolkit.jar:
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)
diff --git a/toolkit/themes/shared/timepicker.css b/toolkit/themes/shared/timepicker.css
new file mode 100644
index 000000000000..5befc1483a54
--- /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);
+}