summaryrefslogtreecommitdiffstats
path: root/toolkit/content
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/content')
-rw-r--r--toolkit/content/browser-content.js16
-rw-r--r--toolkit/content/datepicker.xhtml60
-rw-r--r--toolkit/content/jar.mn4
-rw-r--r--toolkit/content/tests/browser/browser.ini1
-rw-r--r--toolkit/content/tests/browser/browser_datetime_datepicker.js284
-rw-r--r--toolkit/content/tests/browser/head.js90
-rw-r--r--toolkit/content/timepicker.xhtml2
-rw-r--r--toolkit/content/widgets/calendar.js171
-rw-r--r--toolkit/content/widgets/datekeeper.js336
-rw-r--r--toolkit/content/widgets/datepicker.js376
-rw-r--r--toolkit/content/widgets/datetimebox.css12
-rw-r--r--toolkit/content/widgets/datetimebox.xml782
-rw-r--r--toolkit/content/widgets/datetimepicker.xml6
-rw-r--r--toolkit/content/widgets/datetimepopup.xml171
-rw-r--r--toolkit/content/widgets/spinner.js47
-rw-r--r--toolkit/content/widgets/timekeeper.js12
-rw-r--r--toolkit/content/widgets/timepicker.js39
17 files changed, 2254 insertions, 155 deletions
diff --git a/toolkit/content/browser-content.js b/toolkit/content/browser-content.js
index 4ae798fbd..1376f70a3 100644
--- a/toolkit/content/browser-content.js
+++ b/toolkit/content/browser-content.js
@@ -1714,6 +1714,14 @@ let DateTimePickerListener = {
(aEvent.originalTarget.type == "time" && !this.getTimePickerPref())) {
return;
}
+
+ if (this._inputElement) {
+ // This happens when we're trying to open a picker when another picker
+ // is still open. We ignore this request to let the first picker
+ // close gracefully.
+ return;
+ }
+
this._inputElement = aEvent.originalTarget;
this._inputElement.setDateTimePickerState(true);
this.addListeners();
@@ -1728,15 +1736,17 @@ let DateTimePickerListener = {
// element's value.
value: Object.keys(value).length > 0 ? value
: this._inputElement.value,
- step: this._inputElement.step,
- min: this._inputElement.min,
- max: this._inputElement.max,
+ min: this._inputElement.getMinimum(),
+ max: this._inputElement.getMaximum(),
+ step: this._inputElement.getStep(),
+ stepBase: this._inputElement.getStepBase(),
},
});
break;
}
case "MozUpdateDateTimePicker": {
let value = this._inputElement.getDateTimeInputBoxValue();
+ value.type = this._inputElement.type;
sendAsyncMessage("FormDateTime:UpdatePicker", { value });
break;
}
diff --git a/toolkit/content/datepicker.xhtml b/toolkit/content/datepicker.xhtml
new file mode 100644
index 000000000..4da6e398f
--- /dev/null
+++ b/toolkit/content/datepicker.xhtml
@@ -0,0 +1,60 @@
+<?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/datetimeinputpickers.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"/>
+ <button class="right"/>
+ </div>
+ <div class="week-header"></div>
+ <div class="days-viewport">
+ <div class="days-view"></div>
+ </div>
+ </div>
+ <div class="month-year-container">
+ <button class="month-year"/>
+ </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
diff --git a/toolkit/content/jar.mn b/toolkit/content/jar.mn
index 851c72250..f0d4a62a4 100644
--- a/toolkit/content/jar.mn
+++ b/toolkit/content/jar.mn
@@ -45,6 +45,7 @@ toolkit.jar:
content/global/customizeToolbar.js
content/global/customizeToolbar.xul
#endif
+ content/global/datepicker.xhtml
#ifndef MOZ_FENNEC
content/global/editMenuOverlay.js
* content/global/editMenuOverlay.xul
@@ -80,8 +81,11 @@ toolkit.jar:
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)
diff --git a/toolkit/content/tests/browser/browser.ini b/toolkit/content/tests/browser/browser.ini
index 278b2ffe0..67ba2f850 100644
--- a/toolkit/content/tests/browser/browser.ini
+++ b/toolkit/content/tests/browser/browser.ini
@@ -26,6 +26,7 @@ skip-if = !e10s
[browser_contentTitle.js]
[browser_crash_previous_frameloader.js]
run-if = e10s && crashreporter
+[browser_datetime_datepicker.js]
[browser_default_image_filename.js]
[browser_f7_caret_browsing.js]
[browser_findbar.js]
diff --git a/toolkit/content/tests/browser/browser_datetime_datepicker.js b/toolkit/content/tests/browser/browser_datetime_datepicker.js
new file mode 100644
index 000000000..966a74e7a
--- /dev/null
+++ b/toolkit/content/tests/browser/browser_datetime_datepicker.js
@@ -0,0 +1,284 @@
+/* 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";
+
+const MONTH_YEAR = ".month-year",
+ DAYS_VIEW = ".days-view",
+ BTN_PREV_MONTH = ".prev",
+ BTN_NEXT_MONTH = ".next";
+const DATE_FORMAT = new Intl.DateTimeFormat("en-US", { year: "numeric", month: "long", timeZone: "UTC" }).format;
+
+// Create a list of abbreviations for calendar class names
+const W = "weekend",
+ O = "outside",
+ S = "selection",
+ R = "out-of-range",
+ T = "today",
+ P = "off-step";
+
+// Calendar classlist for 2016-12. Used to verify the classNames are correct.
+const calendarClasslist_201612 = [
+ [W, O], [O], [O], [O], [], [], [W],
+ [W], [], [], [], [], [], [W],
+ [W], [], [], [], [S], [], [W],
+ [W], [], [], [], [], [], [W],
+ [W], [], [], [], [], [], [W],
+ [W, O], [O], [O], [O], [O], [O], [W, O],
+];
+
+function getCalendarText() {
+ return helper.getChildren(DAYS_VIEW).map(child => child.textContent);
+}
+
+function getCalendarClassList() {
+ return helper.getChildren(DAYS_VIEW).map(child => Array.from(child.classList));
+}
+
+function mergeArrays(a, b) {
+ return a.map((classlist, index) => classlist.concat(b[index]));
+}
+
+let helper = new DateTimeTestHelper();
+
+registerCleanupFunction(() => {
+ helper.cleanup();
+});
+
+/**
+ * Test that date picker opens to today's date when input field is blank
+ */
+add_task(async function test_datepicker_today() {
+ const date = new Date();
+
+ await helper.openPicker("data:text/html, <input type='date'>");
+
+ Assert.equal(helper.getElement(MONTH_YEAR).textContent, DATE_FORMAT(date));
+
+ await helper.tearDown();
+});
+
+/**
+ * Test that date picker opens to the correct month, with calendar days
+ * displayed correctly, given a date value is set.
+ */
+add_task(async function test_datepicker_open() {
+ const inputValue = "2016-12-15";
+
+ await helper.openPicker(`data:text/html, <input type="date" value="${inputValue}">`);
+
+ Assert.equal(helper.getElement(MONTH_YEAR).textContent, DATE_FORMAT(new Date(inputValue)));
+ Assert.deepEqual(
+ getCalendarText(),
+ [
+ "27", "28", "29", "30", "1", "2", "3",
+ "4", "5", "6", "7", "8", "9", "10",
+ "11", "12", "13", "14", "15", "16", "17",
+ "18", "19", "20", "21", "22", "23", "24",
+ "25", "26", "27", "28", "29", "30", "31",
+ "1", "2", "3", "4", "5", "6", "7",
+ ],
+ "2016-12",
+ );
+ Assert.deepEqual(
+ getCalendarClassList(),
+ calendarClasslist_201612,
+ "2016-12 classNames"
+ );
+
+ await helper.tearDown();
+});
+
+/**
+ * When the prev month button is clicked, calendar should display the dates for
+ * the previous month.
+ */
+add_task(async function test_datepicker_prev_month_btn() {
+ const inputValue = "2016-12-15";
+ const prevMonth = "2016-11-01";
+
+ await helper.openPicker(`data:text/html, <input type="date" value="${inputValue}">`);
+ helper.click(helper.getElement(BTN_PREV_MONTH));
+
+ Assert.equal(helper.getElement(MONTH_YEAR).textContent, DATE_FORMAT(new Date(prevMonth)));
+ Assert.deepEqual(
+ getCalendarText(),
+ [
+ "30", "31", "1", "2", "3", "4", "5",
+ "6", "7", "8", "9", "10", "11", "12",
+ "13", "14", "15", "16", "17", "18", "19",
+ "20", "21", "22", "23", "24", "25", "26",
+ "27", "28", "29", "30", "1", "2", "3",
+ "4", "5", "6", "7", "8", "9", "10",
+ ],
+ "2016-11",
+ );
+
+ await helper.tearDown();
+});
+
+/**
+ * When the next month button is clicked, calendar should display the dates for
+ * the next month.
+ */
+add_task(async function test_datepicker_next_month_btn() {
+ const inputValue = "2016-12-15";
+ const nextMonth = "2017-01-01";
+
+ await helper.openPicker(`data:text/html, <input type="date" value="${inputValue}">`);
+ helper.click(helper.getElement(BTN_NEXT_MONTH));
+
+ Assert.equal(helper.getElement(MONTH_YEAR).textContent, DATE_FORMAT(new Date(nextMonth)));
+ Assert.deepEqual(
+ getCalendarText(),
+ [
+ "25", "26", "27", "28", "29", "30", "31",
+ "1", "2", "3", "4", "5", "6", "7",
+ "8", "9", "10", "11", "12", "13", "14",
+ "15", "16", "17", "18", "19", "20", "21",
+ "22", "23", "24", "25", "26", "27", "28",
+ "29", "30", "31", "1", "2", "3", "4",
+ ],
+ "2017-01",
+ );
+
+ await helper.tearDown();
+});
+
+/**
+ * When a date on the calendar is clicked, date picker should close and set
+ * value to the input box.
+ */
+add_task(async function test_datepicker_clicked() {
+ const inputValue = "2016-12-15";
+ const firstDayOnCalendar = "2016-11-27";
+
+ await helper.openPicker(`data:text/html, <input type="date" value="${inputValue}">`);
+ // Click the first item (top-left corner) of the calendar
+ helper.click(helper.getElement(DAYS_VIEW).children[0]);
+ await ContentTask.spawn(helper.tab.linkedBrowser, {}, async function() {
+ let inputEl = content.document.querySelector("input");
+ await ContentTaskUtils.waitForEvent(inputEl, "input");
+ });
+
+ Assert.equal(content.document.querySelector("input").value, firstDayOnCalendar);
+
+ await helper.tearDown();
+});
+
+/**
+ * Make sure picker is in correct state when it is reopened.
+ */
+add_task(async function test_datepicker_reopen_state() {
+ const inputValue = "2016-12-15";
+ const nextMonth = "2017-01-01";
+
+ await helper.openPicker(`data:text/html, <input type="date" value="${inputValue}">`);
+ // Navigate to the next month but does not commit the change
+ Assert.equal(helper.getElement(MONTH_YEAR).textContent, DATE_FORMAT(new Date(inputValue)));
+ helper.click(helper.getElement(BTN_NEXT_MONTH));
+ Assert.equal(helper.getElement(MONTH_YEAR).textContent, DATE_FORMAT(new Date(nextMonth)));
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, window);
+
+ // Ensures the picker opens to the month of the input value
+ await BrowserTestUtils.synthesizeMouseAtCenter("input", {}, gBrowser.selectedBrowser);
+ await helper.waitForPickerReady();
+ Assert.equal(helper.getElement(MONTH_YEAR).textContent, DATE_FORMAT(new Date(inputValue)));
+
+ await helper.tearDown();
+});
+
+/**
+ * When min and max attributes are set, calendar should show some dates as
+ * out-of-range.
+ */
+add_task(async function test_datepicker_min_max() {
+ const inputValue = "2016-12-15";
+ const inputMin = "2016-12-05";
+ const inputMax = "2016-12-25";
+
+ await helper.openPicker(`data:text/html, <input type="date" value="${inputValue}" min="${inputMin}" max="${inputMax}">`);
+
+ Assert.deepEqual(
+ getCalendarClassList(),
+ mergeArrays(calendarClasslist_201612, [
+ // R denotes out-of-range
+ [R], [R], [R], [R], [R], [R], [R],
+ [R], [], [], [], [], [], [],
+ [], [], [], [], [], [], [],
+ [], [], [], [], [], [], [],
+ [], [R], [R], [R], [R], [R], [R],
+ [R], [R], [R], [R], [R], [R], [R],
+ ]),
+ "2016-12 with min & max",
+ );
+
+ await helper.tearDown();
+});
+
+/**
+ * When step attribute is set, calendar should show some dates as off-step.
+ */
+add_task(async function test_datepicker_step() {
+ const inputValue = "2016-12-15";
+ const inputStep = "5";
+
+ await helper.openPicker(`data:text/html, <input type="date" value="${inputValue}" step="${inputStep}">`);
+
+ Assert.deepEqual(
+ getCalendarClassList(),
+ mergeArrays(calendarClasslist_201612, [
+ // P denotes off-step
+ [P], [P], [P], [], [P], [P], [P],
+ [P], [], [P], [P], [P], [P], [],
+ [P], [P], [P], [P], [], [P], [P],
+ [P], [P], [], [P], [P], [P], [P],
+ [], [P], [P], [P], [P], [], [P],
+ [P], [P], [P], [], [P], [P], [P],
+ ]),
+ "2016-12 with step",
+ );
+
+ await helper.tearDown();
+});
+
+add_task(async function test_datepicker_abs_min() {
+ const inputValue = "0001-01-01";
+ await helper.openPicker(`data:text/html, <input type="date" value="${inputValue}">`);
+
+ Assert.deepEqual(
+ getCalendarText(),
+ [
+ "", "1", "2", "3", "4", "5", "6",
+ "7", "8", "9", "10", "11", "12", "13",
+ "14", "15", "16", "17", "18", "19", "20",
+ "21", "22", "23", "24", "25", "26", "27",
+ "28", "29", "30", "31", "1", "2", "3",
+ "4", "5", "6", "7", "8", "9", "10",
+ ],
+ "0001-01",
+ );
+
+ await helper.tearDown();
+});
+
+add_task(async function test_datepicker_abs_max() {
+ const inputValue = "275760-09-13";
+ await helper.openPicker(`data:text/html, <input type="date" value="${inputValue}">`);
+
+ Assert.deepEqual(
+ getCalendarText(),
+ [
+ "31", "1", "2", "3", "4", "5", "6",
+ "7", "8", "9", "10", "11", "12", "13",
+ "", "", "", "", "", "", "",
+ "", "", "", "", "", "", "",
+ "", "", "", "", "", "", "",
+ "", "", "", "", "", "", "",
+ ],
+ "275760-09",
+ );
+
+ await helper.tearDown();
+});
diff --git a/toolkit/content/tests/browser/head.js b/toolkit/content/tests/browser/head.js
index 1c6c2b54f..d7ed7a9ff 100644
--- a/toolkit/content/tests/browser/head.js
+++ b/toolkit/content/tests/browser/head.js
@@ -31,3 +31,93 @@ function pushPrefs(...aPrefs) {
SpecialPowers.pushPrefEnv({"set": aPrefs}, deferred.resolve);
return deferred.promise;
}
+
+/**
+ * Helper class for testing datetime input picker widget
+ */
+class DateTimeTestHelper {
+ constructor() {
+ this.panel = document.getElementById("DateTimePickerPanel");
+ this.panel.setAttribute("animate", false);
+ this.tab = null;
+ this.frame = null;
+ }
+
+ /**
+ * Opens a new tab with the URL of the test page, and make sure the picker is
+ * ready for testing.
+ *
+ * @param {String} pageUrl
+ */
+ async openPicker(pageUrl) {
+ this.tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, pageUrl);
+ await BrowserTestUtils.synthesizeMouseAtCenter("input", {}, gBrowser.selectedBrowser);
+ // If dateTimePopupFrame doesn't exist yet, wait for the binding to be attached
+ if (!this.panel.dateTimePopupFrame) {
+ await BrowserTestUtils.waitForEvent(this.panel, "DateTimePickerBindingReady")
+ }
+ this.frame = this.panel.dateTimePopupFrame;
+ await this.waitForPickerReady();
+ }
+
+ async waitForPickerReady() {
+ await BrowserTestUtils.waitForEvent(this.frame, "load", true);
+ // Wait for picker elements to be ready
+ await BrowserTestUtils.waitForEvent(this.frame.contentDocument, "PickerReady");
+ }
+
+ /**
+ * Find an element on the picker.
+ *
+ * @param {String} selector
+ * @return {DOMElement}
+ */
+ getElement(selector) {
+ return this.frame.contentDocument.querySelector(selector);
+ }
+
+ /**
+ * Find the children of an element on the picker.
+ *
+ * @param {String} selector
+ * @return {Array<DOMElement>}
+ */
+ getChildren(selector) {
+ return Array.from(this.getElement(selector).children);
+ }
+
+ /**
+ * Click on an element
+ *
+ * @param {DOMElement} element
+ */
+ click(element) {
+ EventUtils.synthesizeMouseAtCenter(element, {}, this.frame.contentWindow);
+ }
+
+ /**
+ * Close the panel and the tab
+ */
+ async tearDown() {
+ if (!this.panel.hidden) {
+ let pickerClosePromise = new Promise(resolve => {
+ this.panel.addEventListener("popuphidden", resolve, {once: true});
+ });
+ this.panel.hidePopup();
+ this.panel.closePicker();
+ await pickerClosePromise;
+ }
+ await BrowserTestUtils.removeTab(this.tab);
+ this.tab = null;
+ }
+
+ /**
+ * Clean up after tests. Remove the frame to prevent leak.
+ */
+ cleanup() {
+ this.frame.remove();
+ this.frame = null;
+ this.panel.removeAttribute("animate");
+ this.panel = null;
+ }
+}
diff --git a/toolkit/content/timepicker.xhtml b/toolkit/content/timepicker.xhtml
index 1396223f1..77b9fba41 100644
--- a/toolkit/content/timepicker.xhtml
+++ b/toolkit/content/timepicker.xhtml
@@ -6,7 +6,7 @@
<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"/>
+ <link rel="stylesheet" href="chrome://global/skin/datetimeinputpickers.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>
diff --git a/toolkit/content/widgets/calendar.js b/toolkit/content/widgets/calendar.js
new file mode 100644
index 000000000..44ba67501
--- /dev/null
+++ b/toolkit/content/widgets/calendar.js
@@ -0,0 +1,171 @@
+/* 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
+ * {Function} getDayString: Transform day number to string
+ * {Function} getWeekHeaderString: Transform day of week number to string
+ * {Function} setSelection: Set selection for dateKeeper
+ * }
+ * @param {Object} context
+ * {
+ * {DOMElement} weekHeader
+ * {DOMElement} daysView
+ * }
+ */
+function Calendar(options, context) {
+ const DAYS_IN_A_WEEK = 7;
+
+ this.context = context;
+ this.state = {
+ days: [],
+ weekHeaders: [],
+ setSelection: options.setSelection,
+ getDayString: options.getDayString,
+ getWeekHeaderString: options.getWeekHeaderString
+ };
+ 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
+ * {
+ * {Date} dateObj
+ * {Number} content
+ * {Array<String>} classNames
+ * {Boolean} enabled
+ * }
+ * {Array<Object>} weekHeaders: Data for weekHeaders
+ * {
+ * {Number} content
+ * {Array<String>} classNames
+ * }
+ * }
+ */
+ setProps(props) {
+ if (props.isVisible) {
+ // Transform the days and weekHeaders array for rendering
+ const days = props.days.map(({ dateObj, content, classNames, enabled }) => {
+ return {
+ dateObj,
+ textContent: this.state.getDayString(content),
+ className: classNames.join(" "),
+ enabled
+ };
+ });
+ const weekHeaders = props.weekHeaders.map(({ content, classNames }) => {
+ return {
+ textContent: this.state.getWeekHeaderString(content),
+ 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;
+ }
+ },
+
+ /**
+ * 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;
+ let targetObj = this.state.days[targetId];
+ if (targetObj.enabled) {
+ this.state.setSelection(targetObj.dateObj);
+ }
+ }
+ break;
+ }
+ }
+ },
+
+ /**
+ * Attach event listener to daysView
+ */
+ _attachEventListeners() {
+ this.context.daysView.addEventListener("click", this);
+ }
+ };
+}
diff --git a/toolkit/content/widgets/datekeeper.js b/toolkit/content/widgets/datekeeper.js
new file mode 100644
index 000000000..5d70416a9
--- /dev/null
+++ b/toolkit/content/widgets/datekeeper.js
@@ -0,0 +1,336 @@
+/* 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.
+ */
+function DateKeeper(props) {
+ this.init(props);
+}
+
+{
+ const DAYS_IN_A_WEEK = 7,
+ MONTHS_IN_A_YEAR = 12,
+ YEAR_VIEW_SIZE = 200,
+ YEAR_BUFFER_SIZE = 10,
+ // The min value is 0001-01-01 based on HTML spec:
+ // https://html.spec.whatwg.org/#valid-date-string
+ MIN_DATE = -62135596800000,
+ // The max value is derived from the ECMAScript spec (275760-09-13):
+ // http://ecma-international.org/ecma-262/5.1/#sec-15.9.1.1
+ MAX_DATE = 8640000000000000,
+ MAX_YEAR = 275760,
+ MAX_MONTH = 9;
+
+ DateKeeper.prototype = {
+ get year() {
+ return this.state.dateObj.getUTCFullYear();
+ },
+
+ get month() {
+ return this.state.dateObj.getUTCMonth();
+ },
+
+ get selection() {
+ return this.state.selection;
+ },
+
+ /**
+ * Initialize DateKeeper
+ * @param {Number} year
+ * @param {Number} month
+ * @param {Number} day
+ * @param {Number} min
+ * @param {Number} max
+ * @param {Number} step
+ * @param {Number} stepBase
+ * @param {Number} firstDayOfWeek
+ * @param {Array<Number>} weekends
+ * @param {Number} calViewSize
+ */
+ init({ year, month, day, min, max, step, stepBase, firstDayOfWeek = 0, weekends = [0], calViewSize = 42 }) {
+ const today = new Date();
+
+ this.state = {
+ step, firstDayOfWeek, weekends, calViewSize,
+ // min & max are NaN if empty or invalid
+ min: new Date(Number.isNaN(min) ? MIN_DATE : min),
+ max: new Date(Number.isNaN(max) ? MAX_DATE : max),
+ stepBase: new Date(stepBase),
+ today: this._newUTCDate(today.getFullYear(), today.getMonth(), today.getDate()),
+ weekHeaders: this._getWeekHeaders(firstDayOfWeek, weekends),
+ years: [],
+ dateObj: new Date(0),
+ selection: { year, month, day },
+ };
+
+ this.setCalendarMonth({
+ year: year === undefined ? today.getFullYear() : year,
+ month: month === undefined ? today.getMonth() : month
+ });
+ },
+ /**
+ * Set new calendar month. The year is always treated as full year, so the
+ * short-form is not supported.
+ * @param {Object} date parts
+ * {
+ * {Number} year [optional]
+ * {Number} month [optional]
+ * }
+ */
+ setCalendarMonth({ year = this.year, month = this.month }) {
+ // Make sure the date is valid before setting.
+ // Use setUTCFullYear so that year 99 doesn't get parsed as 1999
+ if (year > MAX_YEAR || year === MAX_YEAR && month >= MAX_MONTH) {
+ this.state.dateObj.setUTCFullYear(MAX_YEAR, MAX_MONTH - 1, 1);
+ } else if (year < 1 || year === 1 && month < 0) {
+ this.state.dateObj.setUTCFullYear(1, 0, 1);
+ } else {
+ this.state.dateObj.setUTCFullYear(year, month, 1);
+ }
+ },
+
+ /**
+ * Set selection date
+ * @param {Number} year
+ * @param {Number} month
+ * @param {Number} day
+ */
+ setSelection({ year, month, day }) {
+ this.state.selection.year = year;
+ this.state.selection.month = month;
+ this.state.selection.day = day;
+ },
+
+ /**
+ * Set month. Makes sure the day is <= the last day of the month
+ * @param {Number} month
+ */
+ setMonth(month) {
+ this.setCalendarMonth({ year: this.year, month });
+ },
+
+ /**
+ * Set year. Makes sure the day is <= the last day of the month
+ * @param {Number} year
+ */
+ setYear(year) {
+ this.setCalendarMonth({ year, month: this.month });
+ },
+
+ /**
+ * Set month by offset. Makes sure the day is <= the last day of the month
+ * @param {Number} offset
+ */
+ setMonthByOffset(offset) {
+ this.setCalendarMonth({ year: this.year, month: this.month + offset });
+ },
+
+ /**
+ * Generate the array of months
+ * @return {Array<Object>}
+ * {
+ * {Number} value: Month in int
+ * {Boolean} enabled
+ * }
+ */
+ getMonths() {
+ 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() {
+ let years = [];
+
+ const firstItem = this.state.years[0];
+ const lastItem = this.state.years[this.state.years.length - 1];
+ const currentYear = this.year;
+
+ // 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++) {
+ const year = currentYear + i;
+ if (year >= 1 && year <= MAX_YEAR) {
+ years.push({
+ value: year,
+ enabled: true
+ });
+ }
+ }
+ this.state.years = years;
+ }
+ return this.state.years;
+ },
+
+ /**
+ * Get days for calendar
+ * @return {Array<Object>}
+ * {
+ * {Date} dateObj
+ * {Number} content
+ * {Array<String>} classNames
+ * {Boolean} enabled
+ * }
+ */
+ getDays() {
+ const firstDayOfMonth = this._getFirstCalendarDate(this.state.dateObj, this.state.firstDayOfWeek);
+ const month = this.month;
+ let days = [];
+
+ for (let i = 0; i < this.state.calViewSize; i++) {
+ const dateObj = this._newUTCDate(firstDayOfMonth.getUTCFullYear(),
+ firstDayOfMonth.getUTCMonth(),
+ firstDayOfMonth.getUTCDate() + i);
+
+ let classNames = [];
+ let enabled = true;
+
+ const isValid = dateObj.getTime() >= MIN_DATE && dateObj.getTime() <= MAX_DATE;
+ if (!isValid) {
+ classNames.push("out-of-range");
+ enabled = false;
+
+ days.push({
+ classNames,
+ enabled,
+ });
+ continue;
+ }
+
+ const isWeekend = this.state.weekends.includes(dateObj.getUTCDay());
+ const isCurrentMonth = month == dateObj.getUTCMonth();
+ const isSelection = this.state.selection.year == dateObj.getUTCFullYear() &&
+ this.state.selection.month == dateObj.getUTCMonth() &&
+ this.state.selection.day == dateObj.getUTCDate();
+ const isOutOfRange = dateObj.getTime() < this.state.min.getTime() ||
+ dateObj.getTime() > this.state.max.getTime();
+ const isToday = this.state.today.getTime() == dateObj.getTime();
+ const isOffStep = this._checkIsOffStep(dateObj,
+ this._newUTCDate(dateObj.getUTCFullYear(),
+ dateObj.getUTCMonth(),
+ dateObj.getUTCDate() + 1));
+
+ if (isWeekend) {
+ classNames.push("weekend");
+ }
+ if (!isCurrentMonth) {
+ classNames.push("outside");
+ }
+ if (isSelection && !isOutOfRange && !isOffStep) {
+ classNames.push("selection");
+ }
+ if (isOutOfRange) {
+ classNames.push("out-of-range");
+ enabled = false;
+ }
+ if (isToday) {
+ classNames.push("today");
+ }
+ if (isOffStep) {
+ classNames.push("off-step");
+ enabled = false;
+ }
+ days.push({
+ dateObj,
+ content: dateObj.getUTCDate(),
+ classNames,
+ enabled,
+ });
+ }
+ return days;
+ },
+
+ /**
+ * Check if a date is off step given a starting point and the next increment
+ * @param {Date} start
+ * @param {Date} next
+ * @return {Boolean}
+ */
+ _checkIsOffStep(start, next) {
+ // If the increment is larger or equal to the step, it must not be off-step.
+ if (next - start >= this.state.step) {
+ return false;
+ }
+ // Calculate the last valid date
+ const lastValidStep = Math.floor((next - 1 - this.state.stepBase) / this.state.step);
+ const lastValidTimeInMs = lastValidStep * this.state.step + this.state.stepBase.getTime();
+ // The date is off-step if the last valid date is smaller than the start date
+ return lastValidTimeInMs < start.getTime();
+ },
+
+ /**
+ * Get week headers for calendar
+ * @param {Number} firstDayOfWeek
+ * @param {Array<Number>} weekends
+ * @return {Array<Object>}
+ * {
+ * {Number} content
+ * {Array<String>} classNames
+ * }
+ */
+ _getWeekHeaders(firstDayOfWeek, weekends) {
+ let headers = [];
+ let dayOfWeek = firstDayOfWeek;
+
+ for (let i = 0; i < DAYS_IN_A_WEEK; i++) {
+ headers.push({
+ content: dayOfWeek % DAYS_IN_A_WEEK,
+ classNames: weekends.includes(dayOfWeek % DAYS_IN_A_WEEK) ? ["weekend"] : []
+ });
+ dayOfWeek++;
+ }
+ 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(new Date(0).setUTCFullYear(...parts));
+ },
+ };
+}
diff --git a/toolkit/content/widgets/datepicker.js b/toolkit/content/widgets/datepicker.js
new file mode 100644
index 000000000..0e9c9a6e6
--- /dev/null
+++ b/toolkit/content/widgets/datepicker.js
@@ -0,0 +1,376 @@
+/* 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]
+ * {Number} min
+ * {Number} max
+ * {Number} step
+ * {Number} stepBase
+ * {Number} firstDayOfWeek
+ * {Array<Number>} weekends
+ * {Array<String>} monthStrings
+ * {Array<String>} weekdayStrings
+ * {String} locale [optional]: User preferred locale
+ * }
+ */
+ init(props = {}) {
+ this.props = props;
+ this._setDefaultState();
+ this._createComponents();
+ this._update();
+ document.dispatchEvent(new CustomEvent("PickerReady"));
+ },
+
+ /*
+ * Set initial date picker states.
+ */
+ _setDefaultState() {
+ const { year, month, day, min, max, step, stepBase, firstDayOfWeek, weekends,
+ monthStrings, weekdayStrings, locale } = this.props;
+ const dateKeeper = new DateKeeper({
+ year, month, day, min, max, step, stepBase, firstDayOfWeek, weekends,
+ calViewSize: CAL_VIEW_SIZE
+ });
+
+ this.state = {
+ dateKeeper,
+ locale,
+ isMonthPickerVisible: false,
+ getDayString: day => day ? new Intl.NumberFormat(locale).format(day) : "",
+ getWeekHeaderString: weekday => weekdayStrings[weekday],
+ getMonthString: month => monthStrings[month],
+ setSelection: date => {
+ dateKeeper.setSelection({
+ year: date.getUTCFullYear(),
+ month: date.getUTCMonth(),
+ day: date.getUTCDate(),
+ });
+ this._update();
+ this._dispatchState();
+ this._closePopup();
+ },
+ setYear: year => {
+ dateKeeper.setYear(year);
+ dateKeeper.setSelection({
+ year,
+ month: dateKeeper.selection.month,
+ day: dateKeeper.selection.day,
+ });
+ this._update();
+ this._dispatchState();
+ },
+ setMonth: month => {
+ dateKeeper.setMonth(month);
+ dateKeeper.setSelection({
+ year: dateKeeper.selection.year,
+ month,
+ day: dateKeeper.selection.day,
+ });
+ 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,
+ setSelection: this.state.setSelection,
+ getDayString: this.state.getDayString,
+ getWeekHeaderString: this.state.getWeekHeaderString
+ }, {
+ weekHeader: this.context.weekHeader,
+ daysView: this.context.daysView
+ }),
+ monthYear: new MonthYear({
+ setYear: this.state.setYear,
+ setMonth: this.state.setMonth,
+ getMonthString: this.state.getMonthString,
+ locale: this.state.locale
+ }, {
+ monthYear: this.context.monthYear,
+ monthYearView: this.context.monthYearView
+ })
+ };
+ },
+
+ /**
+ * Update date picker and its components.
+ */
+ _update(options = {}) {
+ const { dateKeeper, 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,
+ months: this.state.months,
+ years: this.state.years,
+ toggleMonthPicker: this.state.toggleMonthPicker,
+ noSmoothScroll: options.noSmoothScroll
+ });
+ this.components.calendar.setProps({
+ isVisible: !isMonthPickerVisible,
+ days: this.state.days,
+ weekHeaders: dateKeeper.state.weekHeaders
+ });
+
+ isMonthPickerVisible ?
+ this.context.monthYearView.classList.remove("hidden") :
+ this.context.monthYearView.classList.add("hidden");
+ },
+
+ /**
+ * Use postMessage to close the picker.
+ */
+ _closePopup() {
+ window.postMessage({
+ name: "ClosePopup"
+ }, "*");
+ },
+
+ /**
+ * Use postMessage to pass the state of picker to the panel.
+ */
+ _dispatchState() {
+ const { year, month, day } = this.state.dateKeeper.selection;
+ // The panel is listening to window for postMessage event, so we
+ // do postMessage to itself to send data to input boxes.
+ window.postMessage({
+ name: "PickerPopupChanged",
+ detail: {
+ year,
+ month,
+ day,
+ }
+ }, "*");
+ },
+
+ /**
+ * Attach event listeners
+ */
+ _attachEventListeners() {
+ window.addEventListener("message", this);
+ document.addEventListener("mouseup", this, { passive: true });
+ document.addEventListener("mousedown", this);
+ },
+
+ /**
+ * Handle events.
+ *
+ * @param {Event} event
+ */
+ handleEvent(event) {
+ switch (event.type) {
+ case "message": {
+ this.handleMessage(event);
+ break;
+ }
+ case "mousedown": {
+ // Use preventDefault to keep focus on input boxes
+ event.preventDefault();
+ event.target.setCapture();
+
+ if (event.target == this.context.buttonLeft) {
+ event.target.classList.add("active");
+ this.state.dateKeeper.setMonthByOffset(-1);
+ this._update();
+ } else if (event.target == this.context.buttonRight) {
+ event.target.classList.add("active");
+ this.state.dateKeeper.setMonthByOffset(1);
+ this._update();
+ }
+ break;
+ }
+ case "mouseup": {
+ if (event.target == this.context.buttonLeft || event.target == this.context.buttonRight) {
+ event.target.classList.remove("active");
+ }
+
+ }
+ }
+ },
+
+ /**
+ * Handle postMessage events.
+ *
+ * @param {Event} event
+ */
+ handleMessage(event) {
+ switch (event.data.name) {
+ case "PickerSetValue": {
+ this.set(event.data.detail);
+ break;
+ }
+ case "PickerInit": {
+ 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({ year, month, day }) {
+ const { dateKeeper } = this.state;
+
+ dateKeeper.setCalendarMonth({
+ year, month
+ });
+ dateKeeper.setSelection({
+ year, month, day
+ });
+ this._update({ noSmoothScroll: true });
+ }
+ };
+
+ /**
+ * MonthYear is a component that handles the month & year spinners
+ *
+ * @param {Object} options
+ * {
+ * {String} locale
+ * {Function} setYear
+ * {Function} setMonth
+ * {Function} getMonthString
+ * }
+ * @param {DOMElement} context
+ */
+ function MonthYear(options, context) {
+ const spinnerSize = 5;
+ const yearFormat = new Intl.DateTimeFormat(options.locale, { year: "numeric",
+ timeZone: "UTC" }).format;
+ const dateFormat = new Intl.DateTimeFormat(options.locale, { year: "numeric",
+ month: "long",
+ timeZone: "UTC" }).format;
+ this.context = context;
+ this.state = { dateFormat };
+ this.props = {};
+ this.components = {
+ month: new Spinner({
+ setValue: month => {
+ this.state.isMonthSet = true;
+ options.setMonth(month);
+ },
+ getDisplayString: options.getMonthString,
+ viewportSize: spinnerSize
+ }, context.monthYearView),
+ year: new Spinner({
+ setValue: year => {
+ this.state.isYearSet = true;
+ options.setYear(year);
+ },
+ getDisplayString: year => yearFormat(new Date(new Date(0).setUTCFullYear(year))),
+ viewportSize: spinnerSize
+ }, context.monthYearView)
+ };
+
+ this._attachEventListeners();
+ }
+
+ MonthYear.prototype = {
+
+ /**
+ * Set new properties and pass them to components
+ *
+ * @param {Object} props
+ * {
+ * {Boolean} isVisible
+ * {Date} dateObj
+ * {Array<Object>} months
+ * {Array<Object>} years
+ * {Function} toggleMonthPicker
+ * }
+ */
+ setProps(props) {
+ this.context.monthYear.textContent = this.state.dateFormat(props.dateObj);
+
+ if (props.isVisible) {
+ this.context.monthYear.classList.add("active");
+ this.components.month.setState({
+ value: props.dateObj.getUTCMonth(),
+ items: props.months,
+ isInfiniteScroll: true,
+ isValueSet: this.state.isMonthSet,
+ smoothScroll: !(this.state.firstOpened || props.noSmoothScroll)
+ });
+ this.components.year.setState({
+ value: props.dateObj.getUTCFullYear(),
+ items: props.years,
+ isInfiniteScroll: false,
+ isValueSet: this.state.isYearSet,
+ smoothScroll: !(this.state.firstOpened || props.noSmoothScroll)
+ });
+ this.state.firstOpened = false;
+ } else {
+ this.context.monthYear.classList.remove("active");
+ 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);
+ }
+ };
+}
diff --git a/toolkit/content/widgets/datetimebox.css b/toolkit/content/widgets/datetimebox.css
index 4a9593a69..ce638078f 100644
--- a/toolkit/content/widgets/datetimebox.css
+++ b/toolkit/content/widgets/datetimebox.css
@@ -8,9 +8,17 @@
.datetime-input-box-wrapper {
-moz-appearance: none;
display: inline-flex;
+ flex: 1;
cursor: default;
background-color: inherit;
color: inherit;
+ min-width: 0;
+ justify-content: space-between;
+}
+
+.datetime-input-edit-wrapper {
+ overflow: hidden;
+ white-space: nowrap;
}
.datetime-input {
@@ -20,6 +28,8 @@
border: 0;
margin: 0;
ime-mode: disabled;
+ cursor: default;
+ -moz-user-select: none;
}
.datetime-separator {
@@ -41,5 +51,5 @@
height: 12px;
width: 12px;
align-self: center;
- justify-content: flex-end;
+ flex: none;
}
diff --git a/toolkit/content/widgets/datetimebox.xml b/toolkit/content/widgets/datetimebox.xml
index 05591e65a..94574038a 100644
--- a/toolkit/content/widgets/datetimebox.xml
+++ b/toolkit/content/widgets/datetimebox.xml
@@ -4,12 +4,466 @@
- 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/. -->
+<!DOCTYPE bindings [
+<!ENTITY % datetimeboxDTD SYSTEM "chrome://global/locale/datetimebox.dtd">
+%datetimeboxDTD;
+]>
+
+<!--
+TODO
+Bug 1446342:
+Input type="date" not working if the other form elements has name="document"
+
+Any alternative solution:
+document === window.document
+document === this.ownerDocument
+-->
+
<bindings id="datetimeboxBindings"
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="date-input"
+ extends="chrome://global/content/bindings/datetimebox.xml#datetime-input-base">
+ <resources>
+ <stylesheet src="chrome://global/content/textbox.css"/>
+ <stylesheet src="chrome://global/skin/textbox.css"/>
+ <stylesheet src="chrome://global/content/bindings/datetimebox.css"/>
+ </resources>
+
+ <implementation>
+ <constructor>
+ <![CDATA[
+ /* eslint-disable no-multi-spaces */
+ this.mYearPlaceHolder = ]]>"&date.year.placeholder;"<![CDATA[;
+ this.mMonthPlaceHolder = ]]>"&date.month.placeholder;"<![CDATA[;
+ this.mDayPlaceHolder = ]]>"&date.day.placeholder;"<![CDATA[;
+ this.mSeparatorText = "/";
+ /* eslint-enable no-multi-spaces */
+
+ this.mMinMonth = 1;
+ this.mMaxMonth = 12;
+ this.mMinDay = 1;
+ this.mMaxDay = 31;
+ this.mMinYear = 1;
+ // Maximum year limited by ECMAScript date object range, year <= 275760.
+ this.mMaxYear = 275760;
+ this.mMonthDayLength = 2;
+ this.mYearLength = 4;
+ this.mMonthPageUpDownInterval = 3;
+ this.mDayPageUpDownInterval = 7;
+ this.mYearPageUpDownInterval = 10;
+
+ // Default to en-US, month-day-year order.
+ this.mMonthField =
+ window.document.getAnonymousElementByAttribute(this, "anonid", "input-one");
+ this.mDayField =
+ window.document.getAnonymousElementByAttribute(this, "anonid", "input-two");
+ this.mYearField =
+ window.document.getAnonymousElementByAttribute(this, "anonid", "input-three");
+ this.mYearField.size = this.mYearLength;
+ this.mYearField.maxLength = this.mMaxYear.toString().length;
+
+ this.mMonthField.placeholder = this.mMonthPlaceHolder;
+ this.mDayField.placeholder = this.mDayPlaceHolder;
+ this.mYearField.placeholder = this.mYearPlaceHolder;
+
+ this.mMonthField.setAttribute("min", this.mMinMonth);
+ this.mMonthField.setAttribute("max", this.mMaxMonth);
+ this.mMonthField.setAttribute("pginterval",
+ this.mMonthPageUpDownInterval);
+ this.mDayField.setAttribute("min", this.mMinDay);
+ this.mDayField.setAttribute("max", this.mMaxDay);
+ this.mDayField.setAttribute("pginterval", this.mDayPageUpDownInterval);
+ this.mYearField.setAttribute("min", this.mMinYear);
+ this.mYearField.setAttribute("max", this.mMaxYear);
+ this.mYearField.setAttribute("pginterval",
+ this.mYearPageUpDownInterval);
+
+ this.mDaySeparator =
+ window.document.getAnonymousElementByAttribute(this, "anonid", "sep-first");
+ this.mDaySeparator.textContent = this.mSeparatorText;
+ this.mYearSeparator =
+ window.document.getAnonymousElementByAttribute(this, "anonid", "sep-second");
+ this.mYearSeparator.textContent = this.mSeparatorText;
+
+ if (this.mInputElement.value) {
+ this.setFieldsFromInputValue();
+ }
+ this.updateResetButtonVisibility();
+ ]]>
+ </constructor>
+
+ <method name="clearInputFields">
+ <parameter name="aFromInputElement"/>
+ <body>
+ <![CDATA[
+ this.log("clearInputFields");
+
+ if (this.isDisabled() || this.isReadonly()) {
+ return;
+ }
+
+ if (this.mMonthField && !this.mMonthField.disabled &&
+ !this.mMonthField.readOnly) {
+ this.mMonthField.value = "";
+ this.mMonthField.setAttribute("typeBuffer", "");
+ }
+
+ if (this.mDayField && !this.mDayField.disabled &&
+ !this.mDayField.readOnly) {
+ this.mDayField.value = "";
+ this.mDayField.setAttribute("typeBuffer", "");
+ }
+
+ if (this.mYearField && !this.mYearField.disabled &&
+ !this.mYearField.readOnly) {
+ this.mYearField.value = "";
+ this.mYearField.setAttribute("typeBuffer", "");
+ }
+
+ if (!aFromInputElement && this.mInputElement.value) {
+ this.mInputElement.setUserInput("");
+ }
+
+ this.updateResetButtonVisibility();
+ ]]>
+ </body>
+ </method>
+
+ <method name="setFieldsFromInputValue">
+ <body>
+ <![CDATA[
+ let value = this.mInputElement.value;
+ if (!value) {
+ this.clearInputFields(true);
+ return;
+ }
+
+ this.log("setFieldsFromInputValue: " + value);
+ let [year, month, day] = value.split("-");
+
+ this.setFieldValue(this.mYearField, year);
+ this.setFieldValue(this.mMonthField, month);
+ this.setFieldValue(this.mDayField, day);
+
+ this.notifyPicker();
+ ]]>
+ </body>
+ </method>
+
+ <method name="getDaysInMonth">
+ <parameter name="aMonth"/>
+ <parameter name="aYear"/>
+ <body>
+ <![CDATA[
+ // Javascript's month is 0-based, so this means last day of the
+ // previous month.
+ return new Date(aYear, aMonth, 0).getDate();
+ ]]>
+ </body>
+ </method>
+
+ <method name="isFieldInvalid">
+ <parameter name="aField"/>
+ <body>
+ <![CDATA[
+ if (this.isEmpty(aField.value)) {
+ return true;
+ }
+
+ let min = Number(aField.getAttribute("min"));
+ let max = Number(aField.getAttribute("max"));
+
+ if (Number(aField.value) < min || Number(aField.value) > max) {
+ return true;
+ }
+
+ return false;
+ ]]>
+ </body>
+ </method>
+
+ <method name="setInputValueFromFields">
+ <body>
+ <![CDATA[
+ if (!this.isAnyValueAvailable(false) && this.mInputElement.value) {
+ // Values in the input box was cleared, clear the input element's
+ // value if not empty.
+ this.mInputElement.setUserInput("");
+ return;
+ }
+
+ if (this.isFieldInvalid(this.mYearField) ||
+ this.isFieldInvalid(this.mMonthField) ||
+ this.isFieldInvalid(this.mDayField)) {
+ // We still need to notify picker in case any of the field has
+ // changed. If we can set input element value, then notifyPicker
+ // will be called in setFieldsFromInputValue().
+ this.notifyPicker();
+ return;
+ }
+
+ let year = this.mYearField.value;
+ let month = this.mMonthField.value;
+ let day = this.mDayField.value;
+
+ if (day > this.getDaysInMonth(month, year)) {
+ // Don't set invalid date, otherwise input element's value will be
+ // set to empty.
+ return;
+ }
+
+ let date = [year, month, day].join("-");
+
+ if (date == this.mInputElement.value) {
+ return;
+ }
+
+ this.log("setInputValueFromFields: " + date);
+ this.mInputElement.setUserInput(date);
+ ]]>
+ </body>
+ </method>
+
+ <method name="setFieldsFromPicker">
+ <parameter name="aValue"/>
+ <body>
+ <![CDATA[
+ let year = aValue.year;
+ let month = aValue.month;
+ let day = aValue.day;
+
+ if (!this.isEmpty(year)) {
+ this.setFieldValue(this.mYearField, year);
+ }
+
+ if (!this.isEmpty(month)) {
+ this.setFieldValue(this.mMonthField, month);
+ }
+
+ if (!this.isEmpty(day)) {
+ this.setFieldValue(this.mDayField, day);
+ }
+
+ // Update input element's .value if needed.
+ this.setInputValueFromFields();
+ ]]>
+ </body>
+ </method>
+
+ <method name="handleKeypress">
+ <parameter name="aEvent"/>
+ <body>
+ <![CDATA[
+ if (this.isDisabled() || this.isReadonly()) {
+ return;
+ }
+
+ let targetField = aEvent.originalTarget;
+ let key = aEvent.key;
+
+ if (targetField.classList.contains("numeric") && key.match(/[0-9]/)) {
+ let buffer = targetField.getAttribute("typeBuffer") || "";
+
+ buffer = buffer.concat(key);
+ this.setFieldValue(targetField, buffer);
+ targetField.select();
+
+ let n = Number(buffer);
+ let max = targetField.getAttribute("max");
+ if (buffer.length >= targetField.maxLength || n * 10 > max) {
+ buffer = "";
+ this.advanceToNextField();
+ }
+ targetField.setAttribute("typeBuffer", buffer);
+ }
+ ]]>
+ </body>
+ </method>
+
+ <method name="incrementFieldValue">
+ <parameter name="aTargetField"/>
+ <parameter name="aTimes"/>
+ <body>
+ <![CDATA[
+ let value;
+
+ // Use current date if field is empty.
+ if (this.isEmpty(aTargetField.value)) {
+ let now = new Date();
+
+ if (aTargetField == this.mYearField) {
+ value = now.getFullYear();
+ } else if (aTargetField == this.mMonthField) {
+ value = now.getMonth() + 1;
+ } else if (aTargetField == this.mDayField) {
+ value = now.getDate();
+ } else {
+ this.log("Field not supported in incrementFieldValue.");
+ return;
+ }
+ } else {
+ value = Number(aTargetField.value);
+ }
+
+ let min = Number(aTargetField.getAttribute("min"));
+ let max = Number(aTargetField.getAttribute("max"));
+
+ value += Number(aTimes);
+ if (value > max) {
+ value -= (max - min + 1);
+ } else if (value < min) {
+ value += (max - min + 1);
+ }
+ this.setFieldValue(aTargetField, value);
+ aTargetField.select();
+ ]]>
+ </body>
+ </method>
+
+ <method name="handleKeyboardNav">
+ <parameter name="aEvent"/>
+ <body>
+ <![CDATA[
+ if (this.isDisabled() || this.isReadonly()) {
+ return;
+ }
+
+ let targetField = aEvent.originalTarget;
+ let key = aEvent.key;
+
+ // Home/End key does nothing on year field.
+ if (targetField == this.mYearField && (key == "Home" ||
+ key == "End")) {
+ return;
+ }
+
+ switch (key) {
+ case "ArrowUp":
+ this.incrementFieldValue(targetField, 1);
+ break;
+ case "ArrowDown":
+ this.incrementFieldValue(targetField, -1);
+ break;
+ case "PageUp": {
+ let interval = targetField.getAttribute("pginterval");
+ this.incrementFieldValue(targetField, interval);
+ break;
+ }
+ case "PageDown": {
+ let interval = targetField.getAttribute("pginterval");
+ this.incrementFieldValue(targetField, 0 - interval);
+ break;
+ }
+ case "Home":
+ let min = targetField.getAttribute("min");
+ this.setFieldValue(targetField, min);
+ targetField.select();
+ break;
+ case "End":
+ let max = targetField.getAttribute("max");
+ this.setFieldValue(targetField, max);
+ targetField.select();
+ break;
+ }
+ this.setInputValueFromFields();
+ ]]>
+ </body>
+ </method>
+
+ <method name="getCurrentValue">
+ <body>
+ <![CDATA[
+ let year;
+ if (!this.isEmpty(this.mYearField.value)) {
+ year = Number(this.mYearField.value);
+ }
+
+ let month;
+ if (!this.isEmpty(this.mMonthField.value)) {
+ month = Number(this.mMonthField.value);
+ }
+
+ let day;
+ if (!this.isEmpty(this.mDayField.value)) {
+ day = Number(this.mDayField.value);
+ }
+
+ let date = { year, month, day };
+
+ this.log("getCurrentValue: " + JSON.stringify(date));
+ return date;
+ ]]>
+ </body>
+ </method>
+
+ <method name="setFieldValue">
+ <parameter name="aField"/>
+ <parameter name="aValue"/>
+ <body>
+ <![CDATA[
+ let value = Number(aValue);
+ if (isNaN(value)) {
+ this.log("NaN on setFieldValue!");
+ return;
+ }
+
+ if (aValue.length == aField.maxLength) {
+ let min = Number(aField.getAttribute("min"));
+ let max = Number(aField.getAttribute("max"));
+
+ if (aValue < min) {
+ value = min;
+ } else if (aValue > max) {
+ value = max;
+ }
+ }
+
+ if (aField == this.mMonthField ||
+ aField == this.mDayField) {
+ // prepend zero
+ if (value < 10) {
+ value = "0" + value;
+ }
+ } else {
+ // prepend zeroes
+ if (value < 10) {
+ value = "000" + value;
+ } else if (value < 100) {
+ value = "00" + value;
+ } else if (value < 1000) {
+ value = "0" + value;
+ }
+
+ if (value.toString().length > this.mYearLength &&
+ value.toString().length <= this.mMaxYear.toString().length) {
+ this.mYearField.size = value.toString().length;
+ }
+ }
+
+ aField.value = value;
+ this.updateResetButtonVisibility();
+ ]]>
+ </body>
+ </method>
+
+ <method name="isAnyValueAvailable">
+ <parameter name="aForPicker"/>
+ <body>
+ <![CDATA[
+ return !this.isEmpty(this.mMonthField.value) ||
+ !this.isEmpty(this.mDayField.value) ||
+ !this.isEmpty(this.mYearField.value);
+ ]]>
+ </body>
+ </method>
+
+ </implementation>
+ </binding>
+
<binding id="time-input"
extends="chrome://global/content/bindings/datetimebox.xml#datetime-input-base">
<resources>
@@ -45,13 +499,13 @@
this.mMinSecPageUpDownInterval = 10;
this.mHourField =
- document.getAnonymousElementByAttribute(this, "anonid", "input-one");
+ window.document.getAnonymousElementByAttribute(this, "anonid", "input-one");
this.mHourField.setAttribute("typeBuffer", "");
this.mMinuteField =
- document.getAnonymousElementByAttribute(this, "anonid", "input-two");
+ window.document.getAnonymousElementByAttribute(this, "anonid", "input-two");
this.mMinuteField.setAttribute("typeBuffer", "");
this.mDayPeriodField =
- document.getAnonymousElementByAttribute(this, "anonid", "input-three");
+ window.document.getAnonymousElementByAttribute(this, "anonid", "input-three");
this.mDayPeriodField.classList.remove("numeric");
this.mHourField.placeholder = this.mPlaceHolder;
@@ -64,10 +518,10 @@
this.mMinuteField.setAttribute("max", this.mMaxMinute);
this.mMinuteSeparator =
- document.getAnonymousElementByAttribute(this, "anonid", "sep-first");
+ window.document.getAnonymousElementByAttribute(this, "anonid", "sep-first");
this.mMinuteSeparator.textContent = this.mSeparatorText;
this.mSpaceSeparator =
- document.getAnonymousElementByAttribute(this, "anonid", "sep-second");
+ window.document.getAnonymousElementByAttribute(this, "anonid", "sep-second");
// space between time and am/pm field
this.mSpaceSeparator.textContent = " ";
@@ -79,6 +533,7 @@
if (this.mInputElement.value) {
this.setFieldsFromInputValue();
}
+ this.updateResetButtonVisibility();
]]>
</constructor>
@@ -138,7 +593,7 @@
}
this.log("setFieldsFromInputValue: " + value);
- let [hour, minute, second] = value.split(':');
+ let [hour, minute, second] = value.split(":");
this.setFieldValue(this.mHourField, hour);
this.setFieldValue(this.mMinuteField, minute);
@@ -204,6 +659,13 @@
<method name="setInputValueFromFields">
<body>
<![CDATA[
+ if (!this.isAnyValueAvailable(false) && this.mInputElement.value) {
+ // Values in the input box was cleared, clear the input element's
+ // value if not empty.
+ this.mInputElement.setUserInput("");
+ return;
+ }
+
if (this.isEmpty(this.mHourField.value) ||
this.isEmpty(this.mMinuteField.value) ||
(this.mDayPeriodField && this.isEmpty(this.mDayPeriodField.value)) ||
@@ -239,6 +701,10 @@
time += "." + this.mMillisecField.value;
}
+ if (time == this.mInputElement.value) {
+ return;
+ }
+
this.log("setInputValueFromFields: " + time);
this.mInputElement.setUserInput(time);
]]>
@@ -265,6 +731,9 @@
if (!this.isEmpty(minute)) {
this.setFieldValue(this.mMinuteField, minute);
}
+
+ // Update input element's .value if needed.
+ this.setInputValueFromFields();
]]>
</body>
</method>
@@ -282,21 +751,25 @@
if (this.mHourField && !this.mHourField.disabled &&
!this.mHourField.readOnly) {
this.mHourField.value = "";
+ this.mHourField.setAttribute("typeBuffer", "");
}
if (this.mMinuteField && !this.mMinuteField.disabled &&
!this.mMinuteField.readOnly) {
this.mMinuteField.value = "";
+ this.mMinuteField.setAttribute("typeBuffer", "");
}
if (this.mSecondField && !this.mSecondField.disabled &&
!this.mSecondField.readOnly) {
this.mSecondField.value = "";
+ this.mSecondField.setAttribute("typeBuffer", "");
}
if (this.mMillisecField && !this.mMillisecField.disabled &&
!this.mMillisecField.readOnly) {
this.mMillisecField.value = "";
+ this.mMillisecField.setAttribute("typeBuffer", "");
}
if (this.mDayPeriodField && !this.mDayPeriodField.disabled &&
@@ -304,9 +777,11 @@
this.mDayPeriodField.value = "";
}
- if (!aFromInputElement) {
+ if (!aFromInputElement && this.mInputElement.value) {
this.mInputElement.setUserInput("");
}
+
+ this.updateResetButtonVisibility();
]]>
</body>
</method>
@@ -376,6 +851,7 @@
this.mDayPeriodField.value == this.mAMIndicator ?
this.mPMIndicator : this.mAMIndicator;
this.mDayPeriodField.select();
+ this.updateResetButtonVisibility();
this.setInputValueFromFields();
return;
}
@@ -433,6 +909,7 @@
this.mDayPeriodField.value = this.mPMIndicator;
this.mDayPeriodField.select();
}
+ this.updateResetButtonVisibility();
return;
}
@@ -488,16 +965,30 @@
}
aField.value = value;
+ this.updateResetButtonVisibility();
]]>
</body>
</method>
- <method name="isValueAvailable">
+ <method name="isAnyValueAvailable">
+ <parameter name="aForPicker"/>
<body>
<![CDATA[
+ let available = !this.isEmpty(this.mHourField.value) ||
+ !this.isEmpty(this.mMinuteField.value);
+
+ if (available) {
+ return true;
+ }
+
// Picker only cares about hour:minute.
- return !this.isEmpty(this.mHourField.value) ||
- !this.isEmpty(this.mMinuteField.value);
+ if (aForPicker) {
+ return false;
+ }
+
+ return (this.mDayPeriodField && !this.isEmpty(this.mDayPeriodField.value)) ||
+ (this.mSecondField && !this.isEmpty(this.mSecondField.value)) ||
+ (this.mMillisecField && !this.isEmpty(this.mMillisecField.value));
]]>
</body>
</method>
@@ -546,7 +1037,8 @@
<content>
<html:div class="datetime-input-box-wrapper"
xbl:inherits="context,disabled,readonly">
- <html:span>
+ <html:span class="datetime-input-edit-wrapper"
+ anonid="edit-wrapper">
<html:input anonid="input-one"
class="textbox-input datetime-input numeric"
size="2" maxlength="2"
@@ -563,9 +1055,8 @@
xbl:inherits="disabled,readonly,tabindex"/>
</html:span>
- <html:button class="datetime-reset-button" anoid="reset-button"
- tabindex="-1" xbl:inherits="disabled"
- onclick="document.getBindingParent(this).clearInputFields(false);"/>
+ <html:button class="datetime-reset-button" anonid="reset-button"
+ tabindex="-1" xbl:inherits="disabled"/>
</html:div>
</content>
@@ -579,9 +1070,49 @@
this.mMax = this.mInputElement.max;
this.mStep = this.mInputElement.step;
this.mIsPickerOpen = false;
+
+ this.mResetButton =
+ window.document.getAnonymousElementByAttribute(this, "anonid", "reset-button");
+
+ this.EVENTS.forEach((eventName) => {
+ this.addEventListener(eventName, this, { mozSystemGroup: true });
+ });
+ // Handle keypress separately since we need to catch it on capturing.
+ this.addEventListener("keypress", this, {
+ capture: true,
+ mozSystemGroup: true
+ });
+ // This is to open the picker when input element is clicked (this
+ // includes padding area).
+ this.mInputElement.addEventListener("click", this,
+ { mozSystemGroup: true });
]]>
</constructor>
+ <destructor>
+ <![CDATA[
+ this.EVENTS.forEach((eventName) => {
+ this.removeEventListener(eventName, this, { mozSystemGroup: true });
+ });
+ this.removeEventListener("keypress", this, {
+ capture: true,
+ mozSystemGroup: true
+ });
+ this.mInputElement.removeEventListener("click", this,
+ { mozSystemGroup: true });
+
+ this.mInputElement = null;
+ ]]>
+ </destructor>
+
+ <property name="EVENTS" readonly="true">
+ <getter>
+ <![CDATA[
+ return ["focus", "blur", "copy", "cut", "paste", "mousedown"];
+ ]]>
+ </getter>
+ </property>
+
<method name="log">
<parameter name="aMsg"/>
<body>
@@ -593,11 +1124,23 @@
</body>
</method>
+ <method name="updateResetButtonVisibility">
+ <body>
+ <![CDATA[
+ if (this.isAnyValueAvailable(false)) {
+ this.mResetButton.style.visibility = "visible";
+ } else {
+ this.mResetButton.style.visibility = "hidden";
+ }
+ ]]>
+ </body>
+ </method>
+
<method name="focusInnerTextBox">
<body>
<![CDATA[
this.log("focusInnerTextBox");
- document.getAnonymousElementByAttribute(this, "anonid", "input-one").focus();
+ window.document.getAnonymousElementByAttribute(this, "anonid", "input-one").focus();
]]>
</body>
</method>
@@ -710,10 +1253,22 @@
</body>
</method>
+ <method name="getCurrentValue">
+ <body>
+ throw Components.results.NS_ERROR_NOT_IMPLEMENTED;
+ </body>
+ </method>
+
+ <method name="isAnyValueAvailable">
+ <body>
+ throw Components.results.NS_ERROR_NOT_IMPLEMENTED;
+ </body>
+ </method>
+
<method name="notifyPicker">
<body>
<![CDATA[
- if (this.mIsPickerOpen && this.isValueAvailable()) {
+ if (this.mIsPickerOpen && this.isAnyValueAvailable(true)) {
this.mInputElement.updateDateTimePicker(this.getCurrentValue());
}
]]>
@@ -736,72 +1291,153 @@
</body>
</method>
- </implementation>
-
- <handlers>
- <handler event="focus">
- <![CDATA[
- this.log("focus on: " + event.originalTarget);
+ <method name="handleEvent">
+ <parameter name="aEvent"/>
+ <body>
+ <![CDATA[
+ this.log("handleEvent: " + aEvent.type);
- let target = event.originalTarget;
- if (target.type == "text") {
- this.mLastFocusedField = target;
- target.select();
- }
- ]]>
- </handler>
+ switch (aEvent.type) {
+ case "keypress": {
+ this.onKeyPress(aEvent);
+ break;
+ }
+ case "click": {
+ this.onClick(aEvent);
+ break;
+ }
+ case "focus": {
+ this.onFocus(aEvent);
+ break;
+ }
+ case "blur": {
+ this.onBlur(aEvent);
+ break;
+ }
+ case "mousedown": {
+ if (aEvent.originalTarget == this.mResetButton) {
+ aEvent.preventDefault();
+ }
+ break;
+ }
+ case "copy":
+ case "cut":
+ case "paste": {
+ aEvent.preventDefault();
+ break;
+ }
+ default:
+ break;
+ }
+ ]]>
+ </body>
+ </method>
- <handler event="blur">
- <![CDATA[
- this.setInputValueFromFields();
- ]]>
- </handler>
+ <method name="onFocus">
+ <parameter name="aEvent"/>
+ <body>
+ <![CDATA[
+ this.log("onFocus originalTarget: " + aEvent.originalTarget);
- <handler event="click">
- <![CDATA[
- // XXX: .originalTarget is not expected.
- // When clicking on one of the inner text boxes, the .originalTarget is
- // a HTMLDivElement and when clicking on the reset button, it's a
- // HTMLButtonElement but it's not equal to our reset-button.
- this.log("click on: " + event.originalTarget);
- if (event.defaultPrevented || this.isDisabled() || this.isReadonly()) {
- return;
- }
+ let target = aEvent.originalTarget;
+ if ((target instanceof HTMLInputElement) && target.type == "text") {
+ this.mLastFocusedField = target;
+ target.select();
+ }
+ ]]>
+ </body>
+ </method>
- if (!(event.originalTarget instanceof HTMLButtonElement)) {
- this.mInputElement.openDateTimePicker(this.getCurrentValue());
- }
- ]]>
- </handler>
+ <method name="onBlur">
+ <parameter name="aEvent"/>
+ <body>
+ <![CDATA[
+ this.log("onBlur originalTarget: " + aEvent.originalTarget +
+ " target: " + aEvent.target);
- <handler event="keypress" phase="capturing">
- <![CDATA[
- let key = event.key;
- this.log("keypress: " + key);
+ let target = aEvent.originalTarget;
+ target.setAttribute("typeBuffer", "");
+ this.setInputValueFromFields();
+ ]]>
+ </body>
+ </method>
- if (key == "Backspace" || key == "Tab") {
- return;
- }
+ <method name="onKeyPress">
+ <parameter name="aEvent"/>
+ <body>
+ <![CDATA[
+ this.log("onKeyPress key: " + aEvent.key);
+
+ switch (aEvent.key) {
+ // Close picker on Enter, Escape or Space key.
+ case "Enter":
+ case "Escape":
+ case " ": {
+ if (this.mIsPickerOpen) {
+ this.mInputElement.closeDateTimePicker();
+ aEvent.preventDefault();
+ }
+ break;
+ }
+ case "Backspace": {
+ let targetField = aEvent.originalTarget;
+ targetField.value = "";
+ targetField.setAttribute("typeBuffer", "");
+ this.updateResetButtonVisibility();
+ this.setInputValueFromFields();
+ aEvent.preventDefault();
+ break;
+ }
+ case "ArrowRight":
+ case "ArrowLeft": {
+ this.advanceToNextField(aEvent.key == "ArrowRight" ? false : true);
+ aEvent.preventDefault();
+ break;
+ }
+ case "ArrowUp":
+ case "ArrowDown":
+ case "PageUp":
+ case "PageDown":
+ case "Home":
+ case "End": {
+ this.handleKeyboardNav(aEvent);
+ aEvent.preventDefault();
+ break;
+ }
+ default: {
+ // printable characters
+ if (aEvent.keyCode == 0 &&
+ !(aEvent.ctrlKey || aEvent.altKey || aEvent.metaKey)) {
+ this.handleKeypress(aEvent);
+ aEvent.preventDefault();
+ }
+ break;
+ }
+ }
+ ]]>
+ </body>
+ </method>
- if (key == "Enter" || key == " ") {
- // Close picker on Enter and Space.
- this.mInputElement.closeDateTimePicker();
- }
+ <method name="onClick">
+ <parameter name="aEvent"/>
+ <body>
+ <![CDATA[
+ this.log("onClick originalTarget: " + aEvent.originalTarget +
+ " target: " + aEvent.target);
- if (key == "ArrowUp" || key == "ArrowDown" ||
- key == "PageUp" || key == "PageDown" ||
- key == "Home" || key == "End") {
- this.handleKeyboardNav(event);
- } else if (key == "ArrowRight" || key == "ArrowLeft") {
- this.advanceToNextField((key == "ArrowRight" ? false : true));
- } else {
- this.handleKeypress(event);
- }
+ if (aEvent.defaultPrevented || this.isDisabled() || this.isReadonly()) {
+ return;
+ }
- event.preventDefault();
- ]]>
- </handler>
- </handlers>
+ if (aEvent.originalTarget == this.mResetButton) {
+ this.clearInputFields(false);
+ } else if (!this.mIsPickerOpen) {
+ this.mInputElement.openDateTimePicker(this.getCurrentValue());
+ }
+ ]]>
+ </body>
+ </method>
+ </implementation>
</binding>
</bindings>
diff --git a/toolkit/content/widgets/datetimepicker.xml b/toolkit/content/widgets/datetimepicker.xml
index 5f16f1ff0..1d6a5e772 100644
--- a/toolkit/content/widgets/datetimepicker.xml
+++ b/toolkit/content/widgets/datetimepicker.xml
@@ -999,13 +999,13 @@
<body>
<![CDATA[
var locale = Intl.DateTimeFormat().resolvedOptions().locale + "-u-ca-gregory";
- var dtfMonth = Intl.DateTimeFormat(locale, {month: "long"});
+ var dtfMonth = Intl.DateTimeFormat(locale, {month: "long", timeZone: "UTC"});
var dtfWeekday = Intl.DateTimeFormat(locale, {weekday: "narrow"});
var monthLabel = this.monthField.firstChild;
- var tempDate = new Date(2005, 0, 1);
+ var tempDate = new Date(Date.UTC(2005, 0, 1));
for (var month = 0; month < 12; month++) {
- tempDate.setMonth(month);
+ tempDate.setUTCMonth(month);
monthLabel.setAttribute("value", dtfMonth.format(tempDate));
monthLabel = monthLabel.nextSibling;
}
diff --git a/toolkit/content/widgets/datetimepopup.xml b/toolkit/content/widgets/datetimepopup.xml
index 327f45368..b4335e1ce 100644
--- a/toolkit/content/widgets/datetimepopup.xml
+++ b/toolkit/content/widgets/datetimepopup.xml
@@ -11,17 +11,31 @@
xmlns:xbl="http://www.mozilla.org/xbl">
<binding id="datetime-popup"
extends="chrome://global/content/bindings/popup.xml#arrowpanel">
+ <resources>
+ <stylesheet src="chrome://global/skin/datetimepopup.css"/>
+ </resources>
<implementation>
<field name="dateTimePopupFrame">
this.querySelector("#dateTimePopupFrame");
</field>
<field name="TIME_PICKER_WIDTH" readonly="true">"12em"</field>
<field name="TIME_PICKER_HEIGHT" readonly="true">"21em"</field>
- <method name="loadPicker">
+ <field name="DATE_PICKER_WIDTH" readonly="true">"23.1em"</field>
+ <field name="DATE_PICKER_HEIGHT" readonly="true">"20.7em"</field>
+ <constructor><![CDATA[
+ this.l10n = {};
+ const mozIntl = Components.classes["@mozilla.org/mozintl;1"]
+ .getService(Components.interfaces.mozIMozIntl);
+ mozIntl.addGetCalendarInfo(l10n);
+ mozIntl.addGetDisplayNames(l10n);
+ // Notify DateTimePickerHelper.jsm that binding is ready.
+ this.dispatchEvent(new CustomEvent("DateTimePickerBindingReady"));
+ ]]></constructor>
+ <method name="openPicker">
<parameter name="type"/>
+ <parameter name="anchor"/>
<parameter name="detail"/>
<body><![CDATA[
- this.hidden = false;
this.type = type;
this.pickerState = {};
// TODO: Resize picker according to content zoom level
@@ -35,18 +49,28 @@
this.dateTimePopupFrame.style.height = this.TIME_PICKER_HEIGHT;
break;
}
+ case "date": {
+ this.detail = detail;
+ this.dateTimePopupFrame.addEventListener("load", this, true);
+ this.dateTimePopupFrame.setAttribute("src", "chrome://global/content/datepicker.xhtml");
+ this.dateTimePopupFrame.style.width = this.DATE_PICKER_WIDTH;
+ this.dateTimePopupFrame.style.height = this.DATE_PICKER_HEIGHT;
+ break;
+ }
}
+ this.hidden = false;
+ this.openPopup(anchor, "after_start", 0, 0);
]]></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.contentDocument.removeEventListener("message", this, false);
this.dateTimePopupFrame.setAttribute("src", "");
+ this.hidden = true;
]]></body>
</method>
<method name="setPopupValue">
@@ -55,25 +79,39 @@
switch (this.type) {
case "time": {
this.postMessageToPicker({
- name: "TimePickerSetValue",
+ name: "PickerSetValue",
detail: data.value
});
break;
}
+ case "date": {
+ const { year, month, day } = data.value;
+ this.postMessageToPicker({
+ name: "PickerSetValue",
+ detail: {
+ year,
+ // Month value from input box starts from 1 instead of 0
+ month: month == undefined ? undefined : month - 1,
+ day
+ }
+ });
+ break;
+ }
}
]]></body>
</method>
<method name="initPicker">
<parameter name="detail"/>
<body><![CDATA[
+ const locale = Components.classes["@mozilla.org/chrome/chrome-registry;1"].getService(Ci.nsIXULChromeRegistry).getSelectedLocale("global");
+
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",
+ name: "PickerInit",
detail: {
hour,
minute,
@@ -86,6 +124,56 @@
});
break;
}
+ case "date": {
+ const { year, month, day } = detail.value;
+ const { firstDayOfWeek, weekends } =
+ this.getCalendarInfo(locale);
+ const monthStrings = this.getDisplayNames(
+ locale, [
+ "dates/gregorian/months/january",
+ "dates/gregorian/months/february",
+ "dates/gregorian/months/march",
+ "dates/gregorian/months/april",
+ "dates/gregorian/months/may",
+ "dates/gregorian/months/june",
+ "dates/gregorian/months/july",
+ "dates/gregorian/months/august",
+ "dates/gregorian/months/september",
+ "dates/gregorian/months/october",
+ "dates/gregorian/months/november",
+ "dates/gregorian/months/december",
+ ], "short");
+ const weekdayStrings = this.getDisplayNames(
+ locale, [
+ "dates/gregorian/weekdays/sunday",
+ "dates/gregorian/weekdays/monday",
+ "dates/gregorian/weekdays/tuesday",
+ "dates/gregorian/weekdays/wednesday",
+ "dates/gregorian/weekdays/thursday",
+ "dates/gregorian/weekdays/friday",
+ "dates/gregorian/weekdays/saturday",
+ ], "short");
+
+ this.postMessageToPicker({
+ name: "PickerInit",
+ detail: {
+ year,
+ // Month value from input box starts from 1 instead of 0
+ month: month == undefined ? undefined : month - 1,
+ day,
+ firstDayOfWeek,
+ weekends,
+ monthStrings,
+ weekdayStrings,
+ locale,
+ min: detail.min,
+ max: detail.max,
+ step: detail.step,
+ stepBase: detail.stepBase,
+ }
+ });
+ break;
+ }
}
]]></body>
</method>
@@ -109,6 +197,10 @@
}
break;
}
+ case "date": {
+ this.sendPickerValueChanged(this.pickerState);
+ break;
+ }
}
]]></body>
</method>
@@ -125,9 +217,60 @@
}));
break;
}
+ case "date": {
+ this.dispatchEvent(new CustomEvent("DateTimePickerValueChanged", {
+ detail: {
+ year: value.year,
+ // Month value from input box starts from 1 instead of 0
+ month: value.month == undefined ? undefined : value.month + 1,
+ day: value.day
+ }
+ }));
+ break;
+ }
}
]]></body>
</method>
+ <method name="getCalendarInfo">
+ <parameter name="locale"/>
+ <body><![CDATA[
+ const calendarInfo = this.l10n.getCalendarInfo(locale);
+
+ // Day of week from calendarInfo starts from 1 as Sunday to 7 as Saturday,
+ // so they need to be mapped to JavaScript convention with 0 as Sunday
+ // and 6 as Saturday
+ let firstDayOfWeek = calendarInfo.firstDayOfWeek - 1,
+ weekendStart = calendarInfo.weekendStart - 1,
+ weekendEnd = calendarInfo.weekendEnd - 1;
+
+ let weekends = [];
+
+ // Make sure weekendEnd is greater than weekendStart
+ if (weekendEnd < weekendStart) {
+ weekendEnd += 7;
+ }
+
+ // We get the weekends by incrementing weekendStart up to weekendEnd.
+ // If the start and end is the same day, then weekends only has one day.
+ for (let day = weekendStart; day <= weekendEnd; day++) {
+ weekends.push(day % 7);
+ }
+
+ return {
+ firstDayOfWeek,
+ weekends
+ }
+ ]]></body>
+ </method>
+ <method name="getDisplayNames">
+ <parameter name="locale"/>
+ <parameter name="keys"/>
+ <parameter name="style"/>
+ <body><![CDATA[
+ const displayNames = this.l10n.getDisplayNames(locale, {keys, style});
+ return keys.map(key => displayNames.values[key]);
+ ]]></body>
+ </method>
<method name="handleEvent">
<parameter name="aEvent"/>
<body><![CDATA[
@@ -152,11 +295,16 @@
}
switch (aEvent.data.name) {
- case "TimePickerPopupChanged": {
+ case "PickerPopupChanged": {
this.pickerState = aEvent.data.detail;
this.setInputBoxValue();
break;
}
+ case "ClosePopup": {
+ this.hidePopup();
+ this.closePicker();
+ break;
+ }
}
]]></body>
</method>
@@ -170,12 +318,5 @@
</method>
</implementation>
- <handlers>
- <handler event="popuphiding">
- <![CDATA[
- this.closePicker();
- ]]>
- </handler>
- </handlers>
</binding>
</bindings>
diff --git a/toolkit/content/widgets/spinner.js b/toolkit/content/widgets/spinner.js
index 208ab1931..4901320b5 100644
--- a/toolkit/content/widgets/spinner.js
+++ b/toolkit/content/widgets/spinner.js
@@ -98,7 +98,7 @@ function Spinner(props, context) {
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);
@@ -106,23 +106,23 @@ function Spinner(props, context) {
this._scrollTo(newValue, true);
} else if (newValue != value) {
this.state = Object.assign(this.state, newState);
- this._smoothScrollTo(newValue);
- }
-
- if (isValueSet) {
- if (isInvalid) {
- this._removeSelection();
+ if (smoothScroll) {
+ this._smoothScrollTo(newValue, true);
} else {
- this._updateSelection();
+ this._scrollTo(newValue, true);
}
}
+
+ 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
* - If the value has changed, update the [value] state and call [setValue]
* - If infinite scrolling is on, reset the scrolling position if necessary
*/
@@ -135,14 +135,8 @@ function Spinner(props, context) {
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) {
+ // Call setValue if value has changed
+ if (this.state.value != value) {
this.state.value = value;
this.props.setValue(value);
}
@@ -266,11 +260,11 @@ function Spinner(props, context) {
* Attach event listeners to the spinner and buttons.
*/
_attachEventListeners() {
- const { spinner } = this.elements;
+ const { spinner, container } = this.elements;
spinner.addEventListener("scroll", this, { passive: true });
- document.addEventListener("mouseup", this, { passive: true });
- document.addEventListener("mousedown", this);
+ container.addEventListener("mouseup", this, { passive: true });
+ container.addEventListener("mousedown", this, { passive: true });
},
/**
@@ -288,9 +282,6 @@ function Spinner(props, context) {
break;
}
case "mousedown": {
- // Use preventDefault to keep focus on input boxes
- event.preventDefault();
- event.target.setCapture();
this.state.mouseState = {
down: true,
layerX: event.layerX,
@@ -300,11 +291,11 @@ function Spinner(props, context) {
// An "active" class is needed to simulate :active pseudo-class
// because element is not focused.
event.target.classList.add("active");
- this._smoothScrollToIndex(index + 1);
+ this._smoothScrollToIndex(index - 1);
}
if (event.target == down) {
event.target.classList.add("active");
- this._smoothScrollToIndex(index - 1);
+ this._smoothScrollToIndex(index + 1);
}
if (event.target.parentNode == spinner) {
// Listen to dragging events
@@ -444,10 +435,6 @@ function Spinner(props, context) {
_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"
});
diff --git a/toolkit/content/widgets/timekeeper.js b/toolkit/content/widgets/timekeeper.js
index 2234c9e50..3b4e7eb0a 100644
--- a/toolkit/content/widgets/timekeeper.js
+++ b/toolkit/content/widgets/timekeeper.js
@@ -14,7 +14,7 @@
* {
* {Date} min
* {Date} max
- * {Number} stepInMs
+ * {Number} step
* {String} format: Either "12" or "24"
* }
*/
@@ -286,15 +286,15 @@ function TimeKeeper(props) {
* }
*/
_getSteps(startValue, endValue, minStep, formatter) {
- const { min, max, stepInMs } = this.props;
+ const { min, max, step } = 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);
+ const timeStep = Math.max(minStep, step);
// 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 maxValue = min.valueOf() + Math.floor((max.valueOf() - min.valueOf()) / step) * step;
let steps = [];
// Increment by timeStep until reaching the end of the range.
@@ -410,9 +410,9 @@ function TimeKeeper(props) {
* @return {Boolean}
*/
_isOffStep(time) {
- const { min, stepInMs } = this.props;
+ const { min, step } = this.props;
- return (time.valueOf() - min.valueOf()) % stepInMs != 0;
+ return (time.valueOf() - min.valueOf()) % step != 0;
}
};
}
diff --git a/toolkit/content/widgets/timepicker.js b/toolkit/content/widgets/timepicker.js
index f438e9ec6..1f0463fe4 100644
--- a/toolkit/content/widgets/timepicker.js
+++ b/toolkit/content/widgets/timepicker.js
@@ -13,8 +13,6 @@ function TimePicker(context) {
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 = {
@@ -24,9 +22,9 @@ function TimePicker(context) {
* {
* {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.
+ * {Number} min: Minimum time, in ms
+ * {Number} max: Maximum time, in ms
+ * {Number} step: Step size in ms
* {String} format [optional]: "12" for 12 hours, "24" for 24 hours format
* {String} locale [optional]: User preferred locale
* }
@@ -51,11 +49,10 @@ function TimePicker(context) {
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,
+ min: new Date(Number.isNaN(min) ? 0 : min),
+ max: new Date(Number.isNaN(max) ? DAY_IN_MS - 1 : max),
+ step,
format: format || "12"
});
timeKeeper.setState({ hour: timerHour, minute: timerMinute });
@@ -64,17 +61,6 @@ function TimePicker(context) {
},
/**
- * 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() {
@@ -206,7 +192,7 @@ function TimePicker(context) {
// 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",
+ name: "PickerPopupChanged",
detail: {
hour,
minute,
@@ -218,6 +204,7 @@ function TimePicker(context) {
},
_attachEventListeners() {
window.addEventListener("message", this);
+ document.addEventListener("mousedown", this);
},
/**
@@ -231,6 +218,12 @@ function TimePicker(context) {
this.handleMessage(event);
break;
}
+ case "mousedown": {
+ // Use preventDefault to keep focus on input boxes
+ event.preventDefault();
+ event.target.setCapture();
+ break;
+ }
}
},
@@ -241,11 +234,11 @@ function TimePicker(context) {
*/
handleMessage(event) {
switch (event.data.name) {
- case "TimePickerSetValue": {
+ case "PickerSetValue": {
this.set(event.data.detail);
break;
}
- case "TimePickerInit": {
+ case "PickerInit": {
this.init(event.data.detail);
break;
}