/* 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"; // This is a UA widget. It runs in per-origin UA widget scope, // to be loaded by UAWidgetsChild.jsm. /* * This is the class of entry. It will construct the actual implementation * according to the value of the "type" property. */ this.DateTimeBoxWidget = class { constructor(shadowRoot) { this.shadowRoot = shadowRoot; this.element = shadowRoot.host; this.document = this.element.ownerDocument; this.window = this.document.defaultView; // The DOMLocalization instance needs to allow for sync methods so that // the placeholder value may be determined and set during the // createEditFieldAndAppend() call. this.l10n = new this.window.DOMLocalization( ["toolkit/global/datetimebox.ftl"], /* aSync = */ true ); } /* * Callback called by UAWidgets right after constructor. */ onsetup() { this.onchange(/* aDestroy = */ false); } /* * Callback called by UAWidgets when the "type" property changes. */ onchange(aDestroy = true) { let newType = this.element.type; if (this.type == newType) { return; } if (aDestroy) { this.teardown(); } this.type = newType; this.setup(); } shouldShowTime() { return this.type == "time" || this.type == "datetime-local"; } shouldShowDate() { return this.type == "date" || this.type == "datetime-local"; } teardown() { this.mInputElement.removeEventListener("keydown", this, { capture: true, mozSystemGroup: true, }); this.mInputElement.removeEventListener("click", this, { mozSystemGroup: true, }); this.CONTROL_EVENTS.forEach(eventName => { this.mDateTimeBoxElement.removeEventListener(eventName, this); }); this.l10n.disconnectRoot(this.shadowRoot); this.removeEditFields(); this.removeEventListenersToField(this.mCalendarButton); this.mInputElement = null; this.shadowRoot.firstChild.remove(); } removeEditFields() { this.removeEventListenersToField(this.mYearField); this.removeEventListenersToField(this.mMonthField); this.removeEventListenersToField(this.mDayField); this.removeEventListenersToField(this.mHourField); this.removeEventListenersToField(this.mMinuteField); this.removeEventListenersToField(this.mSecondField); this.removeEventListenersToField(this.mMillisecField); this.removeEventListenersToField(this.mDayPeriodField); this.mYearField = null; this.mMonthField = null; this.mDayField = null; this.mHourField = null; this.mMinuteField = null; this.mSecondField = null; this.mMillisecField = null; this.mDayPeriodField = null; let root = this.shadowRoot.getElementById("edit-wrapper"); while (root.firstChild) { root.firstChild.remove(); } } rebuildEditFieldsIfNeeded() { if ( this.shouldShowSecondField() == !!this.mSecondField && this.shouldShowMillisecField() == !!this.mMillisecField ) { return; } let focused = this.mInputElement.matches(":focus"); this.removeEditFields(); this.buildEditFields(); if (focused) { this._focusFirstField(); } } _focusFirstField() { this.shadowRoot.querySelector(".datetime-edit-field")?.focus(); } setup() { this.DEBUG = false; this.l10n.connectRoot(this.shadowRoot); this.generateContent(); this.mDateTimeBoxElement = this.shadowRoot.firstChild; this.mCalendarButton = this.shadowRoot.getElementById("calendar-button"); this.mInputElement = this.element; this.mLocales = this.window.getWebExposedLocales(); this.mIsRTL = false; let intlUtils = this.window.intlUtils; if (intlUtils) { this.mIsRTL = intlUtils.isAppLocaleRTL(); } if (this.mIsRTL) { let inputBoxWrapper = this.shadowRoot.getElementById("input-box-wrapper"); inputBoxWrapper.dir = "rtl"; } this.mIsPickerOpen = false; 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; const kDefaultAMString = "AM"; const kDefaultPMString = "PM"; let { amString, pmString } = this.getStringsForLocale(this.mLocales); this.mAMIndicator = amString || kDefaultAMString; this.mPMIndicator = pmString || kDefaultPMString; this.mHour12 = this.is12HourTime(this.mLocales); this.mMillisecSeparatorText = "."; this.mMaxLength = 2; this.mMillisecMaxLength = 3; this.mDefaultStep = 60 * 1000; // in milliseconds this.mMinHour = this.mHour12 ? 1 : 0; this.mMaxHour = this.mHour12 ? 12 : 23; this.mMinMinute = 0; this.mMaxMinute = 59; this.mMinSecond = 0; this.mMaxSecond = 59; this.mMinMillisecond = 0; this.mMaxMillisecond = 999; this.mHourPageUpDownInterval = 3; this.mMinSecPageUpDownInterval = 10; this.mInputElement.addEventListener( "keydown", this, { capture: true, mozSystemGroup: true, }, false ); // This is to open the picker when input element is tapped on Android // (this includes padding area). this.isAndroid = this.window.navigator.appVersion.includes("Android"); if (this.isAndroid) { this.mInputElement.addEventListener( "click", this, { mozSystemGroup: true }, false ); } // Those events are dispatched to
with bubble set // to false. They are trapped inside UA Widget Shadow DOM and are not // dispatched to the document. this.CONTROL_EVENTS.forEach(eventName => { this.mDateTimeBoxElement.addEventListener(eventName, this, {}, false); }); this.buildEditFields(); this.buildCalendarBtn(); this.updateEditAttributes(); if (this.mInputElement.value) { this.setFieldsFromInputValue(); } if (this.mInputElement.matches(":focus")) { this._focusFirstField(); } } generateContent() { /* * Pass the markup through XML parser purely for the reason of loading the localization DTD. * Remove it when migrate to Fluent (bug 1504363). */ const parser = new this.window.DOMParser(); let parserDoc = parser.parseFromString( `
`, "application/xml" ); this.shadowRoot.importNodeAndAppendChildAt( this.shadowRoot, parserDoc.documentElement, true ); this.l10n.translateRoots(); } get FIELD_EVENTS() { return ["focus", "blur", "copy", "cut", "paste"]; } get CONTROL_EVENTS() { return [ "MozDateTimeValueChanged", "MozNotifyMinMaxStepAttrChanged", "MozDateTimeAttributeChanged", "MozPickerValueChanged", "MozSetDateTimePickerState", ]; } addEventListenersToField(aElement) { // These events don't bubble out of the Shadow DOM, so we'll have to add // event listeners specifically on each of the fields, not just // on the this.FIELD_EVENTS.forEach(eventName => { aElement.addEventListener( eventName, this, { mozSystemGroup: true }, false ); }); } removeEventListenersToField(aElement) { if (!aElement) { return; } this.FIELD_EVENTS.forEach(eventName => { aElement.removeEventListener(eventName, this, { mozSystemGroup: true }); }); } log(aMsg) { if (this.DEBUG) { this.window.dump("[DateTimeBox] " + aMsg + "\n"); } } createEditFieldAndAppend( aL10nId, aPlaceholderId, aIsNumeric, aMinDigits, aMaxLength, aMinValue, aMaxValue, aPageUpDownInterval ) { let root = this.shadowRoot.getElementById("edit-wrapper"); let field = this.shadowRoot.createElementAndAppendChildAt(root, "span"); field.classList.add("datetime-edit-field"); field.setAttribute("aria-valuetext", ""); this.setFieldTabIndexAttribute(field); const placeholder = this.l10n.formatValueSync(aPlaceholderId); field.placeholder = placeholder; field.textContent = placeholder; this.l10n.setAttributes(field, aL10nId); field.setAttribute("readonly", this.mInputElement.readOnly); field.setAttribute("disabled", this.mInputElement.disabled); // Set property as well for convenience. field.disabled = this.mInputElement.disabled; field.readOnly = this.mInputElement.readOnly; // Used to store the non-formatted value, cleared when value is // cleared. // DateTimeInputTypeBase::HasBadInput() will read this to decide // if the input has value. field.setAttribute("value", ""); if (aIsNumeric) { field.classList.add("numeric"); // Maximum value allowed. field.setAttribute("min", aMinValue); // Minumim value allowed. field.setAttribute("max", aMaxValue); // Interval when pressing pageUp/pageDown key. field.setAttribute("pginterval", aPageUpDownInterval); // Used to store what the user has already typed in the field, // cleared when value is cleared and when field is blurred. field.setAttribute("typeBuffer", ""); // Minimum digits to display, padded with leading 0s. field.setAttribute("mindigits", aMinDigits); // Maximum length for the field, will be advance to the next field // automatically if exceeded. field.setAttribute("maxlength", aMaxLength); // Set spinbutton ARIA role field.setAttribute("role", "spinbutton"); if (this.mIsRTL) { // Force the direction to be "ltr", so that the field stays in the // same order even when it's empty (with placeholder). By using // "embed", the text inside the element is still displayed based // on its directionality. field.style.unicodeBidi = "embed"; field.style.direction = "ltr"; } } else { // Set generic textbox ARIA role field.setAttribute("role", "textbox"); } return field; } updateCalendarButtonState(isExpanded) { this.mCalendarButton.setAttribute("aria-expanded", isExpanded); } notifyInputElementValueChanged() { this.log("inputElementValueChanged"); this.setFieldsFromInputValue(); } notifyMinMaxStepAttrChanged() { // Second and millisecond part are optional, rebuild edit fields if // needed. this.rebuildEditFieldsIfNeeded(); // Fill in values again. this.setFieldsFromInputValue(); } setValueFromPicker(aValue) { this.setFieldsFromPicker(aValue); } advanceToNextField(aReverse) { this.log("advanceToNextField"); let focusedInput = this.mLastFocusedField; let next = aReverse ? focusedInput.previousElementSibling : focusedInput.nextElementSibling; if (!next && !aReverse) { this.setInputValueFromFields(); return; } while (next) { if (next.matches("span.datetime-edit-field")) { next.focus(); break; } next = aReverse ? next.previousElementSibling : next.nextElementSibling; } } setPickerState(aIsOpen) { this.log("picker is now " + (aIsOpen ? "opened" : "closed")); this.mIsPickerOpen = aIsOpen; // Calendar button's expanded state mirrors this.mIsPickerOpen this.updateCalendarButtonState(this.mIsPickerOpen); } setFieldTabIndexAttribute(field) { if (this.mInputElement.disabled) { field.removeAttribute("tabindex"); } else { field.tabIndex = this.mInputElement.tabIndex; } } updateEditAttributes() { this.log("updateEditAttributes"); let editRoot = this.shadowRoot.getElementById("edit-wrapper"); for (let child of editRoot.querySelectorAll( ":scope > span.datetime-edit-field" )) { // "disabled" and "readonly" must be set as attributes because they // are not defined properties of HTMLSpanElement, and the stylesheet // checks the literal string attribute values. child.setAttribute("disabled", this.mInputElement.disabled); child.setAttribute("readonly", this.mInputElement.readOnly); // Set property as well for convenience. child.disabled = this.mInputElement.disabled; child.readOnly = this.mInputElement.readOnly; this.setFieldTabIndexAttribute(child); } this.mCalendarButton.hidden = this.mInputElement.disabled || this.mInputElement.readOnly || this.mInputElement.type === "time"; } isEmpty(aValue) { return aValue == undefined || 0 === aValue.length; } getFieldValue(aField) { if (!aField || !aField.classList.contains("numeric")) { return undefined; } let value = aField.getAttribute("value"); // Avoid returning 0 when field is empty. return this.isEmpty(value) ? undefined : Number(value); } clearFieldValue(aField) { aField.textContent = aField.placeholder; aField.setAttribute("value", ""); aField.setAttribute("aria-valuetext", ""); if (aField.classList.contains("numeric")) { aField.setAttribute("typeBuffer", ""); } } openDateTimePicker() { this.mInputElement.openDateTimePicker(this.getCurrentValue()); } closeDateTimePicker() { if (this.mIsPickerOpen) { this.mInputElement.closeDateTimePicker(); } } notifyPicker() { if (this.mIsPickerOpen && this.isAnyFieldAvailable(true)) { this.mInputElement.updateDateTimePicker(this.getCurrentValue()); } } isDisabled() { return this.mInputElement.hasAttribute("disabled"); } isReadonly() { return this.mInputElement.hasAttribute("readonly"); } isEditable() { return !this.isDisabled() && !this.isReadonly(); } isRequired() { return this.mInputElement.hasAttribute("required"); } containingTree() { return this.mInputElement.containingShadowRoot || this.document; } handleEvent(aEvent) { this.log("handleEvent: " + aEvent.type); if (!aEvent.isTrusted) { return; } switch (aEvent.type) { case "MozDateTimeValueChanged": { this.notifyInputElementValueChanged(); break; } case "MozNotifyMinMaxStepAttrChanged": { this.notifyMinMaxStepAttrChanged(); break; } case "MozDateTimeAttributeChanged": { this.updateEditAttributes(); break; } case "MozPickerValueChanged": { this.setValueFromPicker(aEvent.detail); break; } case "MozSetDateTimePickerState": { // To handle cases when an input is within a shadow DOM: this.oldFocus = this.window.document.activeElement; this.setPickerState(aEvent.detail); break; } case "keydown": { this.onKeyDown(aEvent); break; } case "click": { this.onClick(aEvent); break; } case "focus": { this.onFocus(aEvent); break; } case "blur": { this.onBlur(aEvent); break; } case "mousedown": case "copy": case "cut": case "paste": { aEvent.preventDefault(); break; } default: break; } } onFocus(aEvent) { this.log("onFocus originalTarget: " + aEvent.originalTarget); if (this.containingTree().activeElement != this.mInputElement) { return; } let target = aEvent.originalTarget; if (target.matches("span.datetime-edit-field")) { if (target.disabled) { return; } this.mLastFocusedField = target; this.mInputElement.setFocusState(true); } if (this.mIsPickerOpen && this.isPickerIrrelevantField(target)) { this.closeDateTimePicker(); } } onBlur(aEvent) { this.log( "onBlur originalTarget: " + aEvent.originalTarget + " target: " + aEvent.target + " rt: " + aEvent.relatedTarget ); // Ignore when the focus moves to the datepicker panel // while the input remains focused (even in another shadow DOM) if (this.document.activeElement === this.oldFocus) { return; } this.oldFocus = null; let target = aEvent.originalTarget; target.setAttribute("typeBuffer", ""); this.setInputValueFromFields(); // No need to set and unset the focus state if the focus is staying within // our input. Same about closing the picker. if (aEvent.relatedTarget != this.mInputElement) { this.mInputElement.setFocusState(false); if (this.mIsPickerOpen) { this.closeDateTimePicker(); } } } isTimeField(field) { return ( field == this.mHourField || field == this.mMinuteField || field == this.mSecondField || field == this.mDayPeriodField ); } shouldOpenDateTimePickerOnKeyDown() { if (!this.mLastFocusedField) { return true; } return !this.isPickerIrrelevantField(this.mLastFocusedField); } shouldOpenDateTimePickerOnClick(target) { return !this.isPickerIrrelevantField(target); } // Whether a given field is irrelevant for the purposes of the datetime // picker. This is useful for datetime-local, which as of right now only // shows a date picker (not a time picker). isPickerIrrelevantField(field) { if (this.type != "datetime-local") { return false; } return this.isTimeField(field); } onKeyDown(aEvent) { this.log("onKeyDown key: " + aEvent.key); switch (aEvent.key) { // Toggle the picker on Space/Enter on Calendar button or Space on input, // close on Escape anywhere. case "Escape": { if (this.mIsPickerOpen) { this.closeDateTimePicker(); aEvent.preventDefault(); } break; } case "Enter": case " ": { // always close, if opened if (this.mIsPickerOpen) { this.closeDateTimePicker(); } else if ( // open on Space from anywhere within the input aEvent.key == " " && this.shouldOpenDateTimePickerOnKeyDown() ) { this.openDateTimePicker(); } else if ( // open from the Calendar button on either keydown aEvent.originalTarget == this.mCalendarButton && this.shouldOpenDateTimePickerOnKeyDown() ) { this.openDateTimePicker(); } else { // Don't preventDefault(); break; } aEvent.preventDefault(); break; } case "Delete": case "Backspace": { if (aEvent.originalTarget == this.mCalendarButton) { // Do not remove Calendar button aEvent.preventDefault(); break; } if (this.isEditable()) { // TODO(emilio, bug 1571533): These functions should look at // defaultPrevented. // Ctrl+Backspace/Delete on non-macOS and // Cmd+Backspace/Delete on macOS to clear the field if (aEvent.getModifierState("Accel")) { // Clear the input's value this.clearInputFields(false); } else { let targetField = aEvent.originalTarget; this.clearFieldValue(targetField); this.setInputValueFromFields(); } aEvent.preventDefault(); } break; } case "ArrowRight": case "ArrowLeft": { this.advanceToNextField(!(aEvent.key == "ArrowRight")); aEvent.preventDefault(); break; } case "ArrowUp": case "ArrowDown": case "PageUp": case "PageDown": case "Home": case "End": { this.handleKeyboardNav(aEvent); aEvent.preventDefault(); break; } default: { // Handle printable characters (e.g. letters, digits and numpad digits) if ( aEvent.key.length === 1 && !(aEvent.ctrlKey || aEvent.altKey || aEvent.metaKey) ) { this.handleKeydown(aEvent); aEvent.preventDefault(); } break; } } } onClick(aEvent) { this.log( "onClick originalTarget: " + aEvent.originalTarget + " target: " + aEvent.target ); if (aEvent.defaultPrevented || !this.isEditable()) { return; } // Toggle the picker on click on the Calendar button on any platform, // and, while on Android, on anywhere within an input field, but a Calendar // is excluded to avoid interfering with the default Calendar behavior if ( aEvent.originalTarget == this.mCalendarButton || (this.isAndroid && aEvent.target != this.mCalendarButton) ) { if ( !this.mIsPickerOpen && this.shouldOpenDateTimePickerOnClick(aEvent.originalTarget) ) { this.openDateTimePicker(); } else { this.closeDateTimePicker(); } } } buildEditFields() { let root = this.shadowRoot.getElementById("edit-wrapper"); let options = {}; if (this.shouldShowTime()) { options.hour = options.minute = "numeric"; options.hour12 = this.mHour12; if (this.shouldShowSecondField()) { options.second = "numeric"; } } if (this.shouldShowDate()) { options.year = options.month = options.day = "numeric"; } let formatter = Intl.DateTimeFormat(this.mLocales, options); formatter.formatToParts(Date.now()).map(part => { switch (part.type) { case "year": this.mYearField = this.createEditFieldAndAppend( "datetime-year", "datetime-year-placeholder", true, this.mYearLength, this.mMaxYear.toString().length, this.mMinYear, this.mMaxYear, this.mYearPageUpDownInterval ); this.addEventListenersToField(this.mYearField); break; case "month": this.mMonthField = this.createEditFieldAndAppend( "datetime-month", "datetime-month-placeholder", true, this.mMonthDayLength, this.mMonthDayLength, this.mMinMonth, this.mMaxMonth, this.mMonthPageUpDownInterval ); this.addEventListenersToField(this.mMonthField); break; case "day": this.mDayField = this.createEditFieldAndAppend( "datetime-day", "datetime-day-placeholder", true, this.mMonthDayLength, this.mMonthDayLength, this.mMinDay, this.mMaxDay, this.mDayPageUpDownInterval ); this.addEventListenersToField(this.mDayField); break; case "hour": this.mHourField = this.createEditFieldAndAppend( "datetime-hour", "datetime-time-placeholder", true, this.mMaxLength, this.mMaxLength, this.mMinHour, this.mMaxHour, this.mHourPageUpDownInterval ); this.addEventListenersToField(this.mHourField); break; case "minute": this.mMinuteField = this.createEditFieldAndAppend( "datetime-minute", "datetime-time-placeholder", true, this.mMaxLength, this.mMaxLength, this.mMinMinute, this.mMaxMinute, this.mMinSecPageUpDownInterval ); this.addEventListenersToField(this.mMinuteField); break; case "second": this.mSecondField = this.createEditFieldAndAppend( "datetime-second", "datetime-time-placeholder", true, this.mMaxLength, this.mMaxLength, this.mMinSecond, this.mMaxSecond, this.mMinSecPageUpDownInterval ); this.addEventListenersToField(this.mSecondField); if (this.shouldShowMillisecField()) { // Intl.DateTimeFormat does not support millisecond, so we // need to handle this on our own. let span = this.shadowRoot.createElementAndAppendChildAt( root, "span" ); span.textContent = this.mMillisecSeparatorText; this.mMillisecField = this.createEditFieldAndAppend( "datetime-millisecond", "datetime-time-placeholder", true, this.mMillisecMaxLength, this.mMillisecMaxLength, this.mMinMillisecond, this.mMaxMillisecond, this.mMinSecPageUpDownInterval ); this.addEventListenersToField(this.mMillisecField); } break; case "dayPeriod": this.mDayPeriodField = this.createEditFieldAndAppend( "datetime-dayperiod", "datetime-time-placeholder", false ); this.addEventListenersToField(this.mDayPeriodField); // Give aria autocomplete hint for am/pm this.mDayPeriodField.setAttribute("aria-autocomplete", "inline"); break; default: let span = this.shadowRoot.createElementAndAppendChildAt( root, "span" ); span.textContent = part.value; break; } }); } buildCalendarBtn() { this.addEventListenersToField(this.mCalendarButton); // This is to open the picker when a Calendar button is clicked (this // includes padding area). this.mCalendarButton.addEventListener( "click", this, { mozSystemGroup: true }, false ); } clearInputFields(aFromInputElement) { this.log("clearInputFields"); if (this.mMonthField) { this.clearFieldValue(this.mMonthField); } if (this.mDayField) { this.clearFieldValue(this.mDayField); } if (this.mYearField) { this.clearFieldValue(this.mYearField); } if (this.mHourField) { this.clearFieldValue(this.mHourField); } if (this.mMinuteField) { this.clearFieldValue(this.mMinuteField); } if (this.mSecondField) { this.clearFieldValue(this.mSecondField); } if (this.mMillisecField) { this.clearFieldValue(this.mMillisecField); } if (this.mDayPeriodField) { this.clearFieldValue(this.mDayPeriodField); } if (!aFromInputElement) { if (this.mInputElement.value) { this.mInputElement.setUserInput(""); } else { this.mInputElement.updateValidityState(); } } } setFieldsFromInputValue() { // Second and millisecond part are optional, rebuild edit fields if // needed. this.rebuildEditFieldsIfNeeded(); let value = this.mInputElement.value; if (!value) { this.clearInputFields(true); return; } let { year, month, day, hour, minute, second, millisecond, } = this.getInputElementValues(); if (this.shouldShowDate()) { this.log("setFieldsFromInputValue: " + value); this.setFieldValue(this.mYearField, year); this.setFieldValue(this.mMonthField, month); this.setFieldValue(this.mDayField, day); } if (this.shouldShowTime()) { if (this.isEmpty(hour) && this.isEmpty(minute)) { this.clearInputFields(true); return; } this.setFieldValue(this.mHourField, hour); this.setFieldValue(this.mMinuteField, minute); if (this.mHour12) { this.setDayPeriodValue( hour >= this.mMaxHour ? this.mPMIndicator : this.mAMIndicator ); } if (this.mSecondField) { this.setFieldValue(this.mSecondField, second || 0); } if (this.mMillisecField) { this.setFieldValue(this.mMillisecField, millisecond || 0); } } this.notifyPicker(); } setInputValueFromFields() { if (this.isAnyFieldEmpty()) { // Clear input element's value if any of the field has been cleared, // otherwise update the validity state, since it may become "not" // invalid if fields are not complete. if (this.mInputElement.value) { this.mInputElement.setUserInput(""); } else { this.mInputElement.updateValidityState(); } // We still need to notify picker in case any of the field has // changed. this.notifyPicker(); return; } let { year, month, day, hour, minute, second, millisecond, dayPeriod, } = this.getCurrentValue(); let time = ""; let date = ""; // Convert to a valid time string according to: // https://html.spec.whatwg.org/multipage/infrastructure.html#valid-time-string if (this.shouldShowTime()) { if (this.mHour12) { if (dayPeriod == this.mPMIndicator && hour < this.mMaxHour) { hour += this.mMaxHour; } else if (dayPeriod == this.mAMIndicator && hour == this.mMaxHour) { hour = 0; } } hour = hour < 10 ? "0" + hour : hour; minute = minute < 10 ? "0" + minute : minute; time = hour + ":" + minute; if (second != undefined) { second = second < 10 ? "0" + second : second; time += ":" + second; } if (millisecond != undefined) { // Convert milliseconds to fraction of second. millisecond = millisecond .toString() .padStart(this.mMillisecMaxLength, "0"); time += "." + millisecond; } } if (this.shouldShowDate()) { // Convert to a valid date string according to: // https://html.spec.whatwg.org/multipage/infrastructure.html#valid-date-string year = year.toString().padStart(this.mYearLength, "0"); month = month < 10 ? "0" + month : month; day = day < 10 ? "0" + day : day; date = [year, month, day].join("-"); } let value; if (date) { value = date; } if (time) { // https://html.spec.whatwg.org/#valid-normalised-local-date-and-time-string value = value ? value + "T" + time : time; } if (value == this.mInputElement.value) { return; } this.log("setInputValueFromFields: " + value); this.notifyPicker(); this.mInputElement.setUserInput(value); } setFieldsFromPicker({ year, month, day, hour, minute }) { if (!this.isEmpty(hour)) { this.setFieldValue(this.mHourField, hour); if (this.mHour12) { this.setDayPeriodValue( hour >= this.mMaxHour ? this.mPMIndicator : this.mAMIndicator ); } } if (!this.isEmpty(minute)) { this.setFieldValue(this.mMinuteField, minute); } 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(); } handleKeydown(aEvent) { if (!this.isEditable()) { return; } let targetField = aEvent.originalTarget; let key = aEvent.key; if (targetField == this.mDayPeriodField) { if (key == "a" || key == "A") { this.setDayPeriodValue(this.mAMIndicator); } else if (key == "p" || key == "P") { this.setDayPeriodValue(this.mPMIndicator); } if (!this.isAnyFieldEmpty()) { this.setInputValueFromFields(); } return; } if (targetField.classList.contains("numeric") && key.match(/[0-9]/)) { let buffer = targetField.getAttribute("typeBuffer") || ""; buffer = buffer.concat(key); this.setFieldValue(targetField, buffer); let n = Number(buffer); let max = targetField.getAttribute("max"); let maxLength = targetField.getAttribute("maxlength"); if (targetField == this.mHourField) { if (n * 10 > 23 || buffer.length === 2) { buffer = ""; this.advanceToNextField(); } } else if (buffer.length >= maxLength || n * 10 > max) { buffer = ""; this.advanceToNextField(); } targetField.setAttribute("typeBuffer", buffer); if (!this.isAnyFieldEmpty()) { this.setInputValueFromFields(); } } } getCurrentValue() { let value = {}; if (this.shouldShowDate()) { value.year = this.getFieldValue(this.mYearField); value.month = this.getFieldValue(this.mMonthField); value.day = this.getFieldValue(this.mDayField); } if (this.shouldShowTime()) { let dayPeriod = this.getDayPeriodValue(); let hour = this.getFieldValue(this.mHourField); if (!this.isEmpty(hour)) { if (this.mHour12) { if (dayPeriod == this.mPMIndicator && hour < this.mMaxHour) { hour += this.mMaxHour; } else if (dayPeriod == this.mAMIndicator && hour == this.mMaxHour) { hour = 0; } } } value.hour = hour; value.dayPeriod = dayPeriod; value.minute = this.getFieldValue(this.mMinuteField); value.second = this.getFieldValue(this.mSecondField); value.millisecond = this.getFieldValue(this.mMillisecField); } this.log("getCurrentValue: " + JSON.stringify(value)); return value; } setFieldValue(aField, aValue) { if (!aField || !aField.classList.contains("numeric")) { return; } let value = Number(aValue); if (isNaN(value)) { this.log("NaN on setFieldValue!"); return; } if (aField == this.mHourField) { if (this.mHour12) { // Try to change to 12hr format if user input is 0 or greater // than 12. switch (true) { case value == 0 && aValue.length == 2: value = this.mMaxHour; this.setDayPeriodValue(this.mAMIndicator); break; case value == this.mMaxHour: this.setDayPeriodValue(this.mPMIndicator); break; case value < 12: if (!this.getDayPeriodValue()) { this.setDayPeriodValue(this.mAMIndicator); } break; case value > 12 && value < 24: value = value % this.mMaxHour; this.setDayPeriodValue(this.mPMIndicator); break; default: value = Math.floor(value / 10); break; } } else if (value > this.mMaxHour) { value = this.mMaxHour; } } let maxLength = aField.getAttribute("maxlength"); if (aValue.length == maxLength) { let min = Number(aField.getAttribute("min")); let max = Number(aField.getAttribute("max")); if (value < min) { value = min; } else if (value > max) { value = max; } } aField.setAttribute("value", value); let minDigits = aField.getAttribute("mindigits"); let formatted = value.toLocaleString(this.mLocales, { minimumIntegerDigits: minDigits, useGrouping: false, }); aField.textContent = formatted; aField.setAttribute("aria-valuetext", formatted); } isAnyFieldAvailable(aForPicker = false) { let { year, month, day, hour, minute, second, millisecond, } = this.getCurrentValue(); if ( !this.isEmpty(year) || !this.isEmpty(month) || !this.isEmpty(day) || !this.isEmpty(hour) || !this.isEmpty(minute) ) { return true; } // Picker doesn't care about seconds / milliseconds / day period. if (aForPicker) { return false; } let dayPeriod = this.getDayPeriodValue(); return ( (this.mDayPeriodField && !this.isEmpty(dayPeriod)) || (this.mSecondField && !this.isEmpty(second)) || (this.mMillisecField && !this.isEmpty(millisecond)) ); } isAnyFieldEmpty() { let { year, month, day, hour, minute, second, millisecond, } = this.getCurrentValue(); return ( (this.mYearField && this.isEmpty(year)) || (this.mMonthField && this.isEmpty(month)) || (this.mDayField && this.isEmpty(day)) || (this.mHourField && this.isEmpty(hour)) || (this.mMinuteField && this.isEmpty(minute)) || (this.mDayPeriodField && this.isEmpty(this.getDayPeriodValue())) || (this.mSecondField && this.isEmpty(second)) || (this.mMillisecField && this.isEmpty(millisecond)) ); } get kMsPerSecond() { return 1000; } get kMsPerMinute() { return 60 * 1000; } getInputElementValues() { let value = this.mInputElement.value; if (value.length === 0) { return {}; } let date, time; let year, month, day, hour, minute, second, millisecond; if (this.type == "date") { date = value; } if (this.type == "time") { time = value; } if (this.type == "datetime-local") { // https://html.spec.whatwg.org/#valid-normalised-local-date-and-time-string [date, time] = value.split("T"); } if (date) { [year, month, day] = date.split("-"); } if (time) { [hour, minute, second] = time.split(":"); if (second) { [second, millisecond] = second.split("."); // Convert fraction of second to milliseconds. if (millisecond && millisecond.length === 1) { millisecond *= 100; } else if (millisecond && millisecond.length === 2) { millisecond *= 10; } } } return { year, month, day, hour, minute, second, millisecond }; } shouldShowSecondField() { if (!this.shouldShowTime()) { return false; } let { second } = this.getInputElementValues(); if (second != undefined) { return true; } let stepBase = this.mInputElement.getStepBase(); if (stepBase % this.kMsPerMinute != 0) { return true; } let step = this.mInputElement.getStep(); if (step % this.kMsPerMinute != 0) { return true; } return false; } shouldShowMillisecField() { if (!this.shouldShowTime()) { return false; } let { millisecond } = this.getInputElementValues(); if (millisecond != undefined) { return true; } let stepBase = this.mInputElement.getStepBase(); if (stepBase % this.kMsPerSecond != 0) { return true; } let step = this.mInputElement.getStep(); if (step % this.kMsPerSecond != 0) { return true; } return false; } getStringsForLocale(aLocales) { this.log("getStringsForLocale: " + aLocales); let intlUtils = this.window.intlUtils; if (!intlUtils) { return {}; } let result = intlUtils.getDisplayNames(this.mLocales, { type: "dayPeriod", style: "short", calendar: "gregory", keys: ["am", "pm"], }); let [amString, pmString] = result.values; return { amString, pmString }; } is12HourTime(aLocales) { let options = new Intl.DateTimeFormat(aLocales, { hour: "numeric", }).resolvedOptions(); return options.hour12; } incrementFieldValue(aTargetField, aTimes) { let value = this.getFieldValue(aTargetField); // Use current time if field is empty. if (this.isEmpty(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 if (aTargetField == this.mHourField) { value = now.getHours(); if (this.mHour12) { value = value % this.mMaxHour || this.mMaxHour; } } else if (aTargetField == this.mMinuteField) { value = now.getMinutes(); } else if (aTargetField == this.mSecondField) { value = now.getSeconds(); } else if (aTargetField == this.mMillisecField) { value = now.getMilliseconds(); } else { this.log("Field not supported in incrementFieldValue."); return; } } let min = +aTargetField.getAttribute("min"); let max = +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); } handleKeyboardNav(aEvent) { if (!this.isEditable()) { return; } let targetField = aEvent.originalTarget; let key = aEvent.key; if (targetField == this.mYearField && (key == "Home" || key == "End")) { // Home/End key does nothing on year field. return; } if (targetField == this.mDayPeriodField) { // Home/End key does nothing on AM/PM field. if (key == "Home" || key == "End") { return; } this.setDayPeriodValue( this.getDayPeriodValue() == this.mAMIndicator ? this.mPMIndicator : this.mAMIndicator ); this.setInputValueFromFields(); 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); break; case "End": let max = targetField.getAttribute("max"); this.setFieldValue(targetField, max); break; } this.setInputValueFromFields(); } getDayPeriodValue() { if (!this.mDayPeriodField) { return ""; } let placeholder = this.mDayPeriodField.placeholder; let value = this.mDayPeriodField.textContent; return value == placeholder ? "" : value; } setDayPeriodValue(aValue) { if (!this.mDayPeriodField) { return; } this.mDayPeriodField.textContent = aValue; this.mDayPeriodField.setAttribute("value", aValue); } };