Files
tubestation/toolkit/content/widgets/datetimebox.js
Emilio Cobos Álvarez bce3b3af5c Bug 1865885 - Ensure the date picker in input[type=date] is disabled by containing fieldset. r=emilio,desktop-theme-reviewers,reusable-components-reviewers,hjones
When the programmatically disabled `<fieldset>` contains an `<input type="date">`, the date input should also be rendered as disabled and the calendar button should be hidden.

This patch also refactors the date input's code and updates the tests affected by the refactoring.

Differential Revision: https://phabricator.services.mozilla.com/D194271
2023-12-22 22:43:13 +00:00

1547 lines
42 KiB
JavaScript

/* 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
// or for type=time inputs (this includes padding area).
this.isAndroid = this.window.navigator.appVersion.includes("Android");
if (this.isAndroid || this.type == "time") {
this.mInputElement.addEventListener(
"click",
this,
{ mozSystemGroup: true },
false
);
}
// Those events are dispatched to <div class="datetimebox"> 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() {
const parser = new this.window.DOMParser();
let parserDoc = parser.parseFromString(
`<div class="datetimebox" xmlns="http://www.w3.org/1999/xhtml" role="none">
<link rel="stylesheet" type="text/css" href="chrome://global/content/bindings/datetimebox.css" />
<div class="datetime-input-box-wrapper" id="input-box-wrapper" role="presentation">
<span class="datetime-input-edit-wrapper"
id="edit-wrapper">
<!-- Each of the date/time input types will append their input child
- elements here -->
</span>
<button data-l10n-id="datetime-calendar" class="datetime-calendar-button" id="calendar-button" aria-expanded="false">
<svg role="none" class="datetime-calendar-button-svg" xmlns="http://www.w3.org/2000/svg" id="calendar-16" viewBox="0 0 16 16" width="16" height="16">
<path d="M13.5 2H13V1c0-.6-.4-1-1-1s-1 .4-1 1v1H5V1c0-.6-.4-1-1-1S3 .4 3 1v1h-.5C1.1 2 0 3.1 0 4.5v9C0 14.9 1.1 16 2.5 16h11c1.4 0 2.5-1.1 2.5-2.5v-9C16 3.1 14.9 2 13.5 2zm0 12.5h-11c-.6 0-1-.4-1-1V6h13v7.5c0 .6-.4 1-1 1z"/>
</svg>
</button>
</div>
</div>`,
"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",
"MozDateTimeShowPickerForJS",
];
}
get showPickerOnClick() {
return this.isAndroid || this.type == "time";
}
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 <input>
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);
// 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) {
if (aValue) {
this.setFieldsFromPicker(aValue);
} else {
this.clearInputFields();
}
}
advanceToNextField(aReverse) {
this.log("advanceToNextField");
let focusedInput = this.mLastFocusedElement;
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) {
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"
)) {
this.setFieldTabIndexAttribute(child);
}
}
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.matches(":disabled");
}
isReadonly() {
return this.mInputElement.matches(":read-only");
}
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": {
this.setPickerState(aEvent.detail);
break;
}
case "MozDateTimeShowPickerForJS": {
this.openDateTimePicker();
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(".datetime-edit-field,.datetime-calendar-button")) {
if (target.disabled) {
return;
}
this.mLastFocusedElement = 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 +
" open: " +
this.mIsPickerOpen
);
let target = aEvent.originalTarget;
target.setAttribute("typeBuffer", "");
this.setInputValueFromFields();
// No need to set and unset the focus state (or closing the picker) if the
// focus is staying within our input.
if (aEvent.relatedTarget == this.mInputElement) {
return;
}
// If we're in chrome and the focus moves to a separate document
// (relatedTarget is null) we also don't want to close it, since it
// could've moved to the datetime popup itself.
if (
!aEvent.relatedTarget &&
this.window.isChromeWindow &&
this.window == this.window.top
) {
return;
}
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.mLastFocusedElement) {
return true;
}
return !this.isPickerIrrelevantField(this.mLastFocusedElement);
}
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;
}
// We toggle the picker on click on the Calendar button on any platform.
// For Android and for type=time inputs, we also toggle the picker when
// clicking on the input field.
//
// We do not toggle the picker when clicking the input field for Calendar
// on desktop to avoid interfering with the default Calendar behavior.
if (
aEvent.originalTarget == this.mCalendarButton ||
this.showPickerOnClick
) {
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);
}
};