diff options
Diffstat (limited to 'toolkit/content')
-rw-r--r-- | toolkit/content/tests/browser/browser.ini | 1 | ||||
-rw-r--r-- | toolkit/content/tests/browser/browser_datetime_datepicker.js | 222 | ||||
-rw-r--r-- | toolkit/content/tests/browser/head.js | 85 | ||||
-rw-r--r-- | toolkit/content/widgets/calendar.js | 22 | ||||
-rw-r--r-- | toolkit/content/widgets/datekeeper.js | 171 | ||||
-rw-r--r-- | toolkit/content/widgets/datepicker.js | 94 | ||||
-rw-r--r-- | toolkit/content/widgets/datetimebox.css | 10 | ||||
-rw-r--r-- | toolkit/content/widgets/datetimebox.xml | 156 | ||||
-rw-r--r-- | toolkit/content/widgets/datetimepicker.xml | 6 | ||||
-rw-r--r-- | toolkit/content/widgets/datetimepopup.xml | 101 | ||||
-rw-r--r-- | toolkit/content/widgets/spinner.js | 4 |
11 files changed, 700 insertions, 172 deletions
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..a0db761b7 --- /dev/null +++ b/toolkit/content/tests/browser/browser_datetime_datepicker.js @@ -0,0 +1,222 @@ +/* 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(); +}); + +/** + * 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(); +}); diff --git a/toolkit/content/tests/browser/head.js b/toolkit/content/tests/browser/head.js index 1c6c2b54f..e3ef19538 100644 --- a/toolkit/content/tests/browser/head.js +++ b/toolkit/content/tests/browser/head.js @@ -31,3 +31,88 @@ 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 BrowserTestUtils.waitForEvent(this.frame, "load", true); + // Wait for picker elements to be ready and open panel transition to end + 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.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/widgets/calendar.js b/toolkit/content/widgets/calendar.js index 72e0d9d61..80c2976e0 100644 --- a/toolkit/content/widgets/calendar.js +++ b/toolkit/content/widgets/calendar.js @@ -54,23 +54,21 @@ function Calendar(options, context) { * { * {Number} textContent * {Array<String>} classNames + * {Boolean} enabled * } * {Function} getDayString: Transform day number to string * {Function} getWeekHeaderString: Transform day of week number to string - * {Function} setValue: Set value for dateKeeper - * {Number} selectionValue: The selection date value + * {Function} setSelection: Set selection for dateKeeper * } */ setProps(props) { if (props.isVisible) { // Transform the days and weekHeaders array for rendering - const days = props.days.map(({ dateValue, textContent, classNames }) => { + const days = props.days.map(({ dateObj, classNames, enabled }) => { return { - dateValue, - textContent: props.getDayString(textContent), - className: dateValue == props.selectionValue ? - classNames.concat("selection").join(" ") : - classNames.join(" ") + textContent: props.getDayString(dateObj.getUTCDate()), + className: classNames.join(" "), + enabled }; }); const weekHeaders = props.weekHeaders.map(({ textContent, classNames }) => { @@ -152,10 +150,10 @@ function Calendar(options, context) { case "click": { if (event.target.parentNode == this.context.daysView) { let targetId = event.target.dataset.id; - this.props.setValue({ - selectionValue: this.props.days[targetId].dateValue, - dateValue: this.props.days[targetId].dateValue - }); + let targetObj = this.props.days[targetId]; + if (targetObj.enabled) { + this.props.setSelection(targetObj.dateObj); + } } break; } diff --git a/toolkit/content/widgets/datekeeper.js b/toolkit/content/widgets/datekeeper.js index de01fdade..9777ee647 100644 --- a/toolkit/content/widgets/datekeeper.js +++ b/toolkit/content/widgets/datekeeper.js @@ -6,41 +6,72 @@ /** * DateKeeper keeps track of the date states. - * - * @param {Object} date parts - * { - * {Number} year - * {Number} month - * {Number} day - * } - * {Object} options - * { - * {Number} firstDayOfWeek [optional] - * {Array<Number>} weekends [optional] - * {Number} calViewSize [optional] - * } */ -function DateKeeper({ year, month, day }, { firstDayOfWeek = 0, weekends = [0], calViewSize = 42 }) { - this.state = { - firstDayOfWeek, weekends, calViewSize, - dateObj: new Date(0), - years: [], - months: [], - days: [] - }; - this.state.weekHeaders = this._getWeekHeaders(firstDayOfWeek); - this._update(year, month, day); +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; + YEAR_BUFFER_SIZE = 10, + // The min and max values are derived from the ECMAScript spec: + // http://ecma-international.org/ecma-262/5.1/#sec-15.9.1.1 + MIN_DATE = -8640000000000000, + MAX_DATE = 8640000000000000; DateKeeper.prototype = { + get year() { + return this.state.dateObj.getUTCFullYear(); + }, + + get month() { + return this.state.dateObj.getUTCMonth(); + }, + + get day() { + return this.state.dateObj.getUTCDate(); + }, + + get selection() { + return this.state.selection; + }, + + /** + * Initialize DateKeeper + * @param {Number} year + * @param {Number} month + * @param {Number} day + * @param {String} min + * @param {String} max + * @param {Number} firstDayOfWeek + * @param {Array<Number>} weekends + * @param {Number} calViewSize + */ + init({ year, month, day, min, max, firstDayOfWeek = 0, weekends = [0], calViewSize = 42 }) { + const today = new Date(); + const isDateSet = year != undefined && month != undefined && day != undefined; + + this.state = { + firstDayOfWeek, weekends, calViewSize, + min: new Date(min != undefined ? min : MIN_DATE), + max: new Date(max != undefined ? max : MAX_DATE), + today: this._newUTCDate(today.getFullYear(), today.getMonth(), today.getDate()), + weekHeaders: this._getWeekHeaders(firstDayOfWeek, weekends), + years: [], + months: [], + days: [], + selection: { year, month, day }, + }; + + this.state.dateObj = isDateSet ? + this._newUTCDate(year, month, day) : + new Date(this.state.today); + }, /** - * Set new date + * Set new date. The year is always treated as full year, so the short-form + * is not supported. * @param {Object} date parts * { * {Number} year [optional] @@ -48,17 +79,21 @@ function DateKeeper({ year, month, day }, { firstDayOfWeek = 0, weekends = [0], * {Number} date [optional] * } */ - set({ year = this.state.year, month = this.state.month, day = this.state.day }) { - this._update(year, month, day); + set({ year = this.year, month = this.month, day = this.day }) { + // Use setUTCFullYear so that year 99 doesn't get parsed as 1999 + this.state.dateObj.setUTCFullYear(year, month, day); }, /** - * Set date with value - * @param {Number} value: Date value + * Set selection date + * @param {Number} year + * @param {Number} month + * @param {Number} day */ - setValue(value) { - const dateObj = new Date(value); - this._update(dateObj.getUTCFullYear(), dateObj.getUTCMonth(), dateObj.getUTCDate()); + setSelection({ year, month, day }) { + this.state.selection.year = year; + this.state.selection.month = month; + this.state.selection.day = day; }, /** @@ -66,8 +101,10 @@ function DateKeeper({ year, month, day }, { firstDayOfWeek = 0, weekends = [0], * @param {Number} month */ setMonth(month) { - const lastDayOfMonth = this._newUTCDate(this.state.year, month + 1, 0).getUTCDate(); - this._update(this.state.year, month, Math.min(this.state.day, lastDayOfMonth)); + const lastDayOfMonth = this._newUTCDate(this.year, month + 1, 0).getUTCDate(); + this.set({ year: this.year, + month, + day: Math.min(this.day, lastDayOfMonth) }); }, /** @@ -75,8 +112,10 @@ function DateKeeper({ year, month, day }, { firstDayOfWeek = 0, weekends = [0], * @param {Number} year */ setYear(year) { - const lastDayOfMonth = this._newUTCDate(year, this.state.month + 1, 0).getUTCDate(); - this._update(year, this.state.month, Math.min(this.state.day, lastDayOfMonth)); + const lastDayOfMonth = this._newUTCDate(year, this.month + 1, 0).getUTCDate(); + this.set({ year, + month: this.month, + day: Math.min(this.day, lastDayOfMonth) }); }, /** @@ -84,22 +123,10 @@ function DateKeeper({ year, month, day }, { firstDayOfWeek = 0, weekends = [0], * @param {Number} offset */ setMonthByOffset(offset) { - const lastDayOfMonth = this._newUTCDate(this.state.year, this.state.month + offset + 1, 0).getUTCDate(); - this._update(this.state.year, this.state.month + offset, Math.min(this.state.day, lastDayOfMonth)); - }, - - /** - * Update the states. - * @param {Number} year [description] - * @param {Number} month [description] - * @param {Number} day [description] - */ - _update(year, month, day) { - // Use setUTCFullYear so that year 99 doesn't get parsed as 1999 - this.state.dateObj.setUTCFullYear(year, month, day); - this.state.year = this.state.dateObj.getUTCFullYear(); - this.state.month = this.state.dateObj.getUTCMonth(); - this.state.day = this.state.dateObj.getUTCDate(); + const lastDayOfMonth = this._newUTCDate(this.year, this.month + offset + 1, 0).getUTCDate(); + this.set({ year: this.year, + month: this.month + offset, + day: Math.min(this.day, lastDayOfMonth) }); }, /** @@ -111,7 +138,6 @@ function DateKeeper({ year, month, day }, { firstDayOfWeek = 0, weekends = [0], * } */ getMonths() { - // TODO: add min/max and step support let months = []; for (let i = 0; i < MONTHS_IN_A_YEAR; i++) { @@ -133,12 +159,11 @@ function DateKeeper({ year, month, day }, { firstDayOfWeek = 0, weekends = [0], * } */ getYears() { - // TODO: add min/max and step support let years = []; const firstItem = this.state.years[0]; const lastItem = this.state.years[this.state.years.length - 1]; - const currentYear = this.state.dateObj.getUTCFullYear(); + 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. @@ -161,30 +186,43 @@ function DateKeeper({ year, month, day }, { firstDayOfWeek = 0, weekends = [0], * Get days for calendar * @return {Array<Object>} * { - * {Number} dateValue - * {Number} textContent + * {Date} dateObj * {Array<String>} classNames + * {Boolean} enabled * } */ getDays() { - // TODO: add min/max and step support - let firstDayOfMonth = this._getFirstCalendarDate(this.state.dateObj, this.state.firstDayOfWeek); + // TODO: add step support + const firstDayOfMonth = this._getFirstCalendarDate(this.state.dateObj, this.state.firstDayOfWeek); + const month = this.month; let days = []; - let month = this.state.dateObj.getUTCMonth(); for (let i = 0; i < this.state.calViewSize; i++) { - let dateObj = this._newUTCDate(firstDayOfMonth.getUTCFullYear(), firstDayOfMonth.getUTCMonth(), firstDayOfMonth.getUTCDate() + i); + const dateObj = this._newUTCDate(firstDayOfMonth.getUTCFullYear(), firstDayOfMonth.getUTCMonth(), firstDayOfMonth.getUTCDate() + i); let classNames = []; + let enabled = true; if (this.state.weekends.includes(dateObj.getUTCDay())) { classNames.push("weekend"); } if (month != dateObj.getUTCMonth()) { classNames.push("outside"); } + if (this.state.selection.year == dateObj.getUTCFullYear() && + this.state.selection.month == dateObj.getUTCMonth() && + this.state.selection.day == dateObj.getUTCDate()) { + classNames.push("selection"); + } + if (dateObj.getTime() < this.state.min.getTime() || dateObj.getTime() > this.state.max.getTime()) { + classNames.push("out-of-range"); + enabled = false; + } + if (this.state.today.getTime() == dateObj.getTime()) { + classNames.push("today"); + } days.push({ - dateValue: dateObj.getTime(), - textContent: dateObj.getUTCDate(), - classNames + dateObj, + classNames, + enabled, }); } return days; @@ -193,20 +231,21 @@ function DateKeeper({ year, month, day }, { firstDayOfWeek = 0, weekends = [0], /** * Get week headers for calendar * @param {Number} firstDayOfWeek + * @param {Array<Number>} weekends * @return {Array<Object>} * { * {Number} textContent * {Array<String>} classNames * } */ - _getWeekHeaders(firstDayOfWeek) { + _getWeekHeaders(firstDayOfWeek, weekends) { let headers = []; let dayOfWeek = firstDayOfWeek; for (let i = 0; i < DAYS_IN_A_WEEK; i++) { headers.push({ textContent: dayOfWeek % DAYS_IN_A_WEEK, - classNames: this.state.weekends.includes(dayOfWeek % DAYS_IN_A_WEEK) ? ["weekend"] : [] + classNames: weekends.includes(dayOfWeek % DAYS_IN_A_WEEK) ? ["weekend"] : [] }); dayOfWeek++; } diff --git a/toolkit/content/widgets/datepicker.js b/toolkit/content/widgets/datepicker.js index 210ca856c..317f0ae94 100644 --- a/toolkit/content/widgets/datepicker.js +++ b/toolkit/content/widgets/datepicker.js @@ -20,6 +20,12 @@ function DatePicker(context) { * {Number} year [optional] * {Number} month [optional] * {Number} date [optional] + * {String} min + * {String} max + * {Number} firstDayOfWeek + * {Array<Number>} weekends + * {Array<String>} monthStrings + * {Array<String>} weekdayStrings * {String} locale [optional]: User preferred locale * } */ @@ -28,57 +34,54 @@ function DatePicker(context) { this._setDefaultState(); this._createComponents(); this._update(); + document.dispatchEvent(new CustomEvent("PickerReady")); }, /* * Set initial date picker states. */ _setDefaultState() { - const now = new Date(); - const { year = now.getFullYear(), - month = now.getMonth(), - day = now.getDate(), - locale } = this.props; - - // TODO: Use calendar info API to get first day of week & weekends - // (Bug 1287503) + const { year, month, day, min, max, firstDayOfWeek, weekends, + monthStrings, weekdayStrings, locale } = this.props; const dateKeeper = new DateKeeper({ - year, month, day - }, { - calViewSize: CAL_VIEW_SIZE, - firstDayOfWeek: 0, - weekends: [0] + year, month, day, min, max, firstDayOfWeek, weekends, + calViewSize: CAL_VIEW_SIZE }); this.state = { dateKeeper, locale, isMonthPickerVisible: false, - isYearSet: false, - isMonthSet: false, - isDateSet: false, getDayString: new Intl.NumberFormat(locale).format, - // TODO: use calendar terms when available (Bug 1287677) - getWeekHeaderString: weekday => ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][weekday], - setValue: ({ dateValue, selectionValue }) => { - dateKeeper.setValue(dateValue); - this.state.selectionValue = selectionValue; - this.state.isYearSet = true; - this.state.isMonthSet = true; - this.state.isDateSet = true; + 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); - this.state.isYearSet = true; + dateKeeper.setSelection({ + year, + month: dateKeeper.selection.month, + day: dateKeeper.selection.day, + }); this._update(); this._dispatchState(); }, setMonth: month => { dateKeeper.setMonth(month); - this.state.isMonthSet = true; + dateKeeper.setSelection({ + year: dateKeeper.selection.year, + month, + day: dateKeeper.selection.day, + }); this._update(); this._dispatchState(); }, @@ -104,6 +107,7 @@ function DatePicker(context) { monthYear: new MonthYear({ setYear: this.state.setYear, setMonth: this.state.setMonth, + getMonthString: this.state.getMonthString, locale: this.state.locale }, { monthYear: this.context.monthYear, @@ -116,7 +120,7 @@ function DatePicker(context) { * Update date picker and its components. */ _update() { - const { dateKeeper, selectionValue, isMonthPickerVisible } = this.state; + const { dateKeeper, isMonthPickerVisible } = this.state; if (isMonthPickerVisible) { this.state.months = dateKeeper.getMonths(); @@ -128,9 +132,7 @@ function DatePicker(context) { this.components.monthYear.setProps({ isVisible: isMonthPickerVisible, dateObj: dateKeeper.state.dateObj, - month: dateKeeper.state.month, months: this.state.months, - year: dateKeeper.state.year, years: this.state.years, toggleMonthPicker: this.state.toggleMonthPicker }); @@ -138,10 +140,9 @@ function DatePicker(context) { isVisible: !isMonthPickerVisible, days: this.state.days, weekHeaders: dateKeeper.state.weekHeaders, - setValue: this.state.setValue, + setSelection: this.state.setSelection, getDayString: this.state.getDayString, - getWeekHeaderString: this.state.getWeekHeaderString, - selectionValue + getWeekHeaderString: this.state.getWeekHeaderString }); isMonthPickerVisible ? @@ -162,8 +163,7 @@ function DatePicker(context) { * Use postMessage to pass the state of picker to the panel. */ _dispatchState() { - const { year, month, day } = this.state.dateKeeper.state; - const { isYearSet, isMonthSet, isDaySet } = this.state; + 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({ @@ -172,9 +172,6 @@ function DatePicker(context) { year, month, day, - isYearSet, - isMonthSet, - isDaySet } }, "*"); }, @@ -255,19 +252,12 @@ function DatePicker(context) { set({ year, month, day }) { const { dateKeeper } = this.state; - if (year != undefined) { - this.state.isYearSet = true; - } - if (month != undefined) { - this.state.isMonthSet = true; - } - if (day != undefined) { - this.state.isDaySet = true; - } - dateKeeper.set({ year, month, day }); + dateKeeper.setSelection({ + year, month, day + }); this._update(); } }; @@ -280,12 +270,12 @@ function DatePicker(context) { * {String} locale * {Function} setYear * {Function} setMonth + * {Function} getMonthString * } * @param {DOMElement} context */ function MonthYear(options, context) { const spinnerSize = 5; - const monthFormat = new Intl.DateTimeFormat(options.locale, { month: "short" }).format; const yearFormat = new Intl.DateTimeFormat(options.locale, { year: "numeric" }).format; const dateFormat = new Intl.DateTimeFormat(options.locale, { year: "numeric", month: "long" }).format; @@ -298,7 +288,7 @@ function DatePicker(context) { this.state.isMonthSet = true; options.setMonth(month); }, - getDisplayString: month => monthFormat(new Date(0, month)), + getDisplayString: options.getMonthString, viewportSize: spinnerSize }, context.monthYearView), year: new Spinner({ @@ -323,8 +313,6 @@ function DatePicker(context) { * { * {Boolean} isVisible * {Date} dateObj - * {Number} month - * {Number} year * {Array<Object>} months * {Array<Object>} years * {Function} toggleMonthPicker @@ -336,14 +324,14 @@ function DatePicker(context) { if (props.isVisible) { this.context.monthYear.classList.add("active"); this.components.month.setState({ - value: props.month, + value: props.dateObj.getUTCMonth(), items: props.months, isInfiniteScroll: true, isValueSet: this.state.isMonthSet, smoothScroll: !this.state.firstOpened }); this.components.year.setState({ - value: props.year, + value: props.dateObj.getUTCFullYear(), items: props.years, isInfiniteScroll: false, isValueSet: this.state.isYearSet, diff --git a/toolkit/content/widgets/datetimebox.css b/toolkit/content/widgets/datetimebox.css index 18ff024c7..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 { @@ -43,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 5859f80dd..c276265a3 100644 --- a/toolkit/content/widgets/datetimebox.xml +++ b/toolkit/content/widgets/datetimebox.xml @@ -4,6 +4,11 @@ - 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; +]> + <bindings id="datetimeboxBindings" xmlns="http://www.mozilla.org/xbl" xmlns:html="http://www.w3.org/1999/xhtml" @@ -21,12 +26,13 @@ <implementation> <constructor> <![CDATA[ - // TODO: Bug 1320227 - [DateTimeInput] localization for - // <input type=date> input box - this.mMonthPlaceHolder = "mm"; - this.mDayPlaceHolder = "dd"; - this.mYearPlaceHolder = "yyyy"; + /* 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; @@ -76,6 +82,7 @@ if (this.mInputElement.value) { this.setFieldsFromInputValue(); } + this.updateResetButtonVisibility(); ]]> </constructor> @@ -107,9 +114,11 @@ this.mYearField.setAttribute("typeBuffer", ""); } - if (!aFromInputElement) { + if (!aFromInputElement && this.mInputElement.value) { this.mInputElement.setUserInput(""); } + + this.updateResetButtonVisibility(); ]]> </body> </method> @@ -170,6 +179,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.isFieldInvalid(this.mYearField) || this.isFieldInvalid(this.mMonthField) || this.isFieldInvalid(this.mDayField)) { @@ -192,6 +208,10 @@ let date = [year, month, day].join("-"); + if (date == this.mInputElement.value) { + return; + } + this.log("setInputValueFromFields: " + date); this.mInputElement.setUserInput(date); ]]> @@ -217,6 +237,9 @@ if (!this.isEmpty(day)) { this.setFieldValue(this.mDayField, day); } + + // Update input element's .value if needed. + this.setInputValueFromFields(); ]]> </body> </method> @@ -302,6 +325,12 @@ 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); @@ -406,11 +435,13 @@ } aField.value = value; + this.updateResetButtonVisibility(); ]]> </body> </method> - <method name="isValueAvailable"> + <method name="isAnyValueAvailable"> + <parameter name="aForPicker"/> <body> <![CDATA[ return !this.isEmpty(this.mMonthField.value) || @@ -492,6 +523,7 @@ if (this.mInputElement.value) { this.setFieldsFromInputValue(); } + this.updateResetButtonVisibility(); ]]> </constructor> @@ -551,7 +583,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); @@ -617,6 +649,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)) || @@ -652,6 +691,10 @@ time += "." + this.mMillisecField.value; } + if (time == this.mInputElement.value) { + return; + } + this.log("setInputValueFromFields: " + time); this.mInputElement.setUserInput(time); ]]> @@ -678,6 +721,9 @@ if (!this.isEmpty(minute)) { this.setFieldValue(this.mMinuteField, minute); } + + // Update input element's .value if needed. + this.setInputValueFromFields(); ]]> </body> </method> @@ -721,9 +767,11 @@ this.mDayPeriodField.value = ""; } - if (!aFromInputElement) { + if (!aFromInputElement && this.mInputElement.value) { this.mInputElement.setUserInput(""); } + + this.updateResetButtonVisibility(); ]]> </body> </method> @@ -793,6 +841,7 @@ this.mDayPeriodField.value == this.mAMIndicator ? this.mPMIndicator : this.mAMIndicator; this.mDayPeriodField.select(); + this.updateResetButtonVisibility(); this.setInputValueFromFields(); return; } @@ -850,6 +899,7 @@ this.mDayPeriodField.value = this.mPMIndicator; this.mDayPeriodField.select(); } + this.updateResetButtonVisibility(); return; } @@ -905,16 +955,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> @@ -963,7 +1027,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" @@ -980,9 +1045,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> @@ -997,6 +1061,9 @@ this.mStep = this.mInputElement.step; this.mIsPickerOpen = false; + this.mResetButton = + document.getAnonymousElementByAttribute(this, "anonid", "reset-button"); + this.EVENTS.forEach((eventName) => { this.addEventListener(eventName, this, { mozSystemGroup: true }); }); @@ -1005,6 +1072,9 @@ capture: true, mozSystemGroup: true }); + // This is to close the picker when input element blurs. + this.mInputElement.addEventListener("blur", this, + { mozSystemGroup: true }); ]]> </constructor> @@ -1025,7 +1095,7 @@ <property name="EVENTS" readonly="true"> <getter> <![CDATA[ - return ["click", "focus", "blur", "copy", "cut", "paste"]; + return ["click", "focus", "blur", "copy", "cut", "paste", "mousedown"]; ]]> </getter> </property> @@ -1041,6 +1111,18 @@ </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[ @@ -1164,10 +1246,16 @@ </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()); } ]]> @@ -1213,6 +1301,12 @@ this.onBlur(aEvent); break; } + case "mousedown": { + if (aEvent.originalTarget == this.mResetButton) { + aEvent.preventDefault(); + } + break; + } case "copy": case "cut": case "paste": { @@ -1245,7 +1339,12 @@ <parameter name="aEvent"/> <body> <![CDATA[ - this.log("onBlur originalTarget: " + aEvent.originalTarget); + this.log("onBlur originalTarget: " + aEvent.originalTarget + + " target: " + aEvent.target); + + if (aEvent.target == this.mInputElement && this.mIsPickerOpen) { + this.mInputElement.closeDateTimePicker(); + } let target = aEvent.originalTarget; target.setAttribute("typeBuffer", ""); @@ -1261,16 +1360,23 @@ this.log("onKeyPress key: " + aEvent.key); switch (aEvent.key) { - // Close picker on Enter or Space key. + // Close picker on Enter, Escape or Space key. case "Enter": + case "Escape": case " ": { - this.mInputElement.closeDateTimePicker(); - aEvent.preventDefault(); + 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": @@ -1312,12 +1418,14 @@ // 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. + // HTMLButtonElement. if (aEvent.defaultPrevented || this.isDisabled() || this.isReadonly()) { return; } - if (!(aEvent.originalTarget instanceof HTMLButtonElement)) { + if (aEvent.originalTarget == this.mResetButton) { + this.clearInputFields(false); + } else if (!this.mIsPickerOpen) { this.mInputElement.openDateTimePicker(this.getCurrentValue()); } ]]> 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 86e8780c1..52df7de75 100644 --- a/toolkit/content/widgets/datetimepopup.xml +++ b/toolkit/content/widgets/datetimepopup.xml @@ -22,11 +22,20 @@ <field name="TIME_PICKER_HEIGHT" readonly="true">"21em"</field> <field name="DATE_PICKER_WIDTH" readonly="true">"23.1em"</field> <field name="DATE_PICKER_HEIGHT" readonly="true">"20.7em"</field> - <method name="loadPicker"> + <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 @@ -49,17 +58,20 @@ 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("message", this, false); this.dateTimePopupFrame.setAttribute("src", ""); + this.hidePopup(); + this.hidden = true; ]]></body> </method> <method name="setPopupValue"> @@ -115,6 +127,34 @@ } 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: { @@ -122,7 +162,13 @@ // Month value from input box starts from 1 instead of 0 month: month == undefined ? undefined : month - 1, day, - locale + firstDayOfWeek, + weekends, + monthStrings, + weekdayStrings, + locale, + min: detail.min, + max: detail.max, } }); break; @@ -184,6 +230,46 @@ } ]]></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[ @@ -230,12 +316,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 059e151fc..6ef929f8a 100644 --- a/toolkit/content/widgets/spinner.js +++ b/toolkit/content/widgets/spinner.js @@ -299,11 +299,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 |