diff options
Diffstat (limited to 'toolkit/content/widgets')
-rw-r--r-- | toolkit/content/widgets/calendar.js | 171 | ||||
-rw-r--r-- | toolkit/content/widgets/datekeeper.js | 336 | ||||
-rw-r--r-- | toolkit/content/widgets/datepicker.js | 376 | ||||
-rw-r--r-- | toolkit/content/widgets/datetimebox.css | 12 | ||||
-rw-r--r-- | toolkit/content/widgets/datetimebox.xml | 782 | ||||
-rw-r--r-- | toolkit/content/widgets/datetimepicker.xml | 6 | ||||
-rw-r--r-- | toolkit/content/widgets/datetimepopup.xml | 171 | ||||
-rw-r--r-- | toolkit/content/widgets/spinner.js | 47 | ||||
-rw-r--r-- | toolkit/content/widgets/timekeeper.js | 12 | ||||
-rw-r--r-- | toolkit/content/widgets/timepicker.js | 39 |
10 files changed, 1801 insertions, 151 deletions
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; } |