Bug 1346085 - Part 1: Use <span> for editable fields in date/time input box. r?mossop draft
authorJessica Jong <jjong@mozilla.com>
Wed, 05 Apr 2017 15:00:13 +0800
changeset 556019 2487eba0f67703bdf2eb8f04ca53df4dc96e8d19
parent 555725 b043233ec04f06768d59dcdfb9e928142280f3cc
child 556020 1e115b7276b7cdb6103123396b9fdb9cbe2874a9
push id52407
push userjjong@mozilla.com
push dateWed, 05 Apr 2017 08:14:48 +0000
reviewersmossop
bugs1346085
milestone55.0a1
Bug 1346085 - Part 1: Use <span> for editable fields in date/time input box. r?mossop When adding support for RTL in date/time input box, we noticed that bidi text was not displayed properly with mixed of inline and block elements. By changing our editabled fields from <input> to <span>, we can rely on bidi algorithm and markups to display the inner fields in the right order. MozReview-Commit-ID: 7r8OVSJXJRU
dom/html/test/forms/test_input_datetime_focus_blur.html
toolkit/content/widgets/datetimebox.css
toolkit/content/widgets/datetimebox.xml
--- a/dom/html/test/forms/test_input_datetime_focus_blur.html
+++ b/dom/html/test/forms/test_input_datetime_focus_blur.html
@@ -41,18 +41,17 @@ function testFocusBlur(type) {
   is(activeElement.localName, "input", "activeElement should be an input element");
   is(activeElement.type, type, "activeElement should be of type " + type);
 
   // Use FocusManager to check that the actual focus is on the anonymous
   // text control.
   let fm = SpecialPowers.Cc["@mozilla.org/focus-manager;1"]
                         .getService(SpecialPowers.Ci.nsIFocusManager);
   let focusedElement = fm.focusedElement;
-  is(focusedElement.localName, "input", "focusedElement should be an input element");
-  is(focusedElement.type, "text", "focusedElement should be of type text");
+  is(focusedElement.localName, "span", "focusedElement should be an span element");
 
   input.blur();
   isnot(document.activeElement, input, "activeElement should no longer be the datetime input element");
 }
 
 function test() {
   let inputTypes = ["time", "date"];
 
--- a/toolkit/content/widgets/datetimebox.css
+++ b/toolkit/content/widgets/datetimebox.css
@@ -6,35 +6,40 @@
 @namespace xul url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul");
 
 .datetime-input-box-wrapper {
   -moz-appearance: none;
   display: inline-flex;
   cursor: default;
   background-color: inherit;
   color: inherit;
+  font-family: monospace;
 }
 
-.datetime-input {
-  -moz-appearance: none;
+.datetime-edit-field {
+  display: inline;
+  cursor: default;
+  -moz-user-select: none;
   text-align: center;
-  padding: 0;
+  padding: 1px 3px;
   border: 0;
   margin: 0;
   ime-mode: disabled;
-  cursor: default;
-  -moz-user-select: none;
+  font: inherit;
+  outline: none;
 }
 
-.datetime-separator {
-  margin: 0 !important;
+.datetime-edit-field:not([disabled="true"]):focus {
+  background-color: Highlight;
+  color: HighlightText;
+  outline: none;
 }
 
-.datetime-input[readonly],
-.datetime-input[disabled] {
+.datetime-edit-field[disabled="true"],
+.datetime-edit-field[readonly="true"]  {
   color: GrayText;
   -moz-user-select: none;
 }
 
 .datetime-reset-button {
   background-image: url(chrome://global/skin/icons/input-clear.svg);
   background-color: transparent;
   background-repeat: no-repeat;
--- a/toolkit/content/widgets/datetimebox.xml
+++ b/toolkit/content/widgets/datetimebox.xml
@@ -55,26 +55,26 @@
 
       <method name="buildEditFields">
         <body>
         <![CDATA[
           const HTML_NS = "http://www.w3.org/1999/xhtml";
           let root =
             document.getAnonymousElementByAttribute(this, "anonid", "edit-wrapper");
 
+          let yearMaxLength = this.mMaxYear.toString().length
           this.mYearField = this.createEditField(this.mYearPlaceHolder,
-            this.mYearLength, true, this.mMinYear, this.mMaxYear,
+            true, this.mYearLength, yearMaxLength, this.mMinYear, this.mMaxYear,
             this.mYearPageUpDownInterval);
-          this.mYearField.maxLength = this.mMaxYear.toString().length;
           this.mMonthField = this.createEditField(this.mMonthPlaceHolder,
-            this.mMonthDayLength, true, this.mMinMonth, this.mMaxMonth,
-            this.mMonthPageUpDownInterval);
+            true, this.mMonthDayLength, this.mMonthDayLength, this.mMinMonth,
+            this.mMaxMonth, this.mMonthPageUpDownInterval);
           this.mDayField = this.createEditField(this.mDayPlaceHolder,
-            this.mMonthDayLength, true, this.mMinDay, this.mMaxDay,
-            this.mDayPageUpDownInterval);
+            true, this.mMonthDayLength, this.mMonthDayLength, this.mMinDay,
+            this.mMaxDay, this.mDayPageUpDownInterval);
 
           let fragment = document.createDocumentFragment();
           let formatter = Intl.DateTimeFormat(this.mLocales, {
             year: "numeric",
             month: "numeric",
             day: "numeric"
           });
           formatter.formatToParts(Date.now()).map(part => {
@@ -122,17 +122,16 @@
           if (this.mDayField && !this.mDayField.disabled &&
               !this.mDayField.readOnly) {
             this.clearFieldValue(this.mDayField);
           }
 
           if (this.mYearField && !this.mYearField.disabled &&
               !this.mYearField.readOnly) {
             this.clearFieldValue(this.mYearField);
-            this.mYearField.size = this.mYearLength;
           }
 
           if (!aFromInputElement) {
             this.mInputElement.setUserInput("");
           }
         ]]>
         </body>
       </method>
@@ -267,17 +266,18 @@
           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");
-            if (buffer.length >= targetField.maxLength || n * 10 > max) {
+            let maxLength = targetField.getAttribute("maxlength");
+            if (buffer.length >= maxLength || n * 10 > max) {
               buffer = "";
               this.advanceToNextField();
             }
             targetField.setAttribute("typeBuffer", buffer);
           }
         ]]>
         </body>
       </method>
@@ -387,46 +387,38 @@
           }
 
           let value = Number(aValue);
           if (isNaN(value)) {
             this.log("NaN on setFieldValue!");
             return;
           }
 
-          if (aValue.length == aField.maxLength) {
+          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("rawValue", value);
 
           // Display formatted value based on locale.
-          let minDigits = (aField == this.mYearField ? this.mYearLength
-                                                     : aField.size);
+          let minDigits = aField.getAttribute("mindigits");
           let formatted = value.toLocaleString(this.mLocales, {
             minimumIntegerDigits: minDigits,
             useGrouping: false
           });
 
-          if (aField == this.mYearField) {
-            this.mYearField.size = formatted.length;
-          }
-
-          aField.value = formatted;
-          if (document.activeElement == this.mInputElement &&
-              this.mLastFocusedField && this.mLastFocusedField == aField) {
-            aField.select();
-          }
+          aField.textContent = formatted;
           this.updateResetButtonVisibility();
         ]]>
         </body>
       </method>
 
       <method name="isAnyValueAvailable">
         <parameter name="aForPicker"/>
         <body>
@@ -553,41 +545,39 @@
 
           let options = {
             hour: "numeric",
             minute: "numeric",
             hour12: this.mHour12
           };
 
           this.mHourField = this.createEditField(this.mHourPlaceHolder,
-            this.mMaxLength, true, this.mMinHour, this.mMaxHour,
-            this.mHourPageUpDownInterval);
+            true, this.mMaxLength, this.mMaxLength, this.mMinHour,
+            this.mMaxHour, this.mHourPageUpDownInterval);
           this.mMinuteField = this.createEditField(this.mMinutePlaceHolder,
-            this.mMaxLength, true, this.mMinMinute, this.mMaxMinute,
-            this.mMinSecPageUpDownInterval);
+            true, this.mMaxLength, this.mMaxLength, this.mMinMinute,
+            this.mMaxMinute, this.mMinSecPageUpDownInterval);
 
           if (this.mHour12) {
-            let dayPeriodLength =
-              Math.max(this.mAMIndicator.length, this.mPMIndicator.length);
-
             this.mDayPeriodField = this.createEditField(
-              this.mDayPeriodPlaceHolder, dayPeriodLength, false);
+              this.mDayPeriodPlaceHolder, false);
           }
 
           if (second != undefined) {
             options["second"] = "numeric";
             this.mSecondField = this.createEditField(this.mSecondPlaceHolder,
-              this.mMaxLength, true, this.mMinSecond, this.mMaxSecond,
+              true, this.mMaxLength, this.mMaxLength, this.mMinSecond, this.mMaxSecond,
               this.mMinSecPageUpDownInterval);
           }
 
           if (millisecond != undefined) {
             this.mMillisecField = this.createEditField(this.mMillisecPlaceHolder,
-              this.mMillisecMaxLength, true, this.mMinMillisecond,
-              this.mMaxMillisecond, this.mMinSecPageUpDownInterval);
+              true, this.mMillisecMaxLength, this.mMillisecMaxLength,
+              this.mMinMillisecond, this.mMaxMillisecond,
+              this.mMinSecPageUpDownInterval);
           }
 
           let fragment = document.createDocumentFragment();
           let formatter = Intl.DateTimeFormat(this.mLocales, options);
           formatter.formatToParts(Date.now()).map(part => {
             switch (part.type) {
               case "hour":
                 fragment.appendChild(this.mHourField);
@@ -708,34 +698,33 @@
       <method name="setInputValueFromFields">
         <body>
         <![CDATA[
           if (!this.isAnyValueAvailable(false)) {
             this.mInputElement.setUserInput("");
             return;
           }
 
-          if (this.isEmpty(this.mHourField.value) ||
-              this.isEmpty(this.mMinuteField.value) ||
-              (this.mDayPeriodField && this.isEmpty(this.mDayPeriodField.value)) ||
-              (this.mSecondField && this.isEmpty(this.mSecondField.value)) ||
-              (this.mMillisecField && this.isEmpty(this.mMillisecField.value))) {
+          let { hour, minute, second, millisecond } = this.getCurrentValue();
+          let dayPeriod = this.getDayPeriodValue();
+
+          if (this.isEmpty(hour) || this.isEmpty(minute) ||
+              (this.mDayPeriodField && this.isEmpty(dayPeriod)) ||
+              (this.mSecondField && this.isEmpty(second)) ||
+              (this.mMillisecField && this.isEmpty(millisecond))) {
             // 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 { hour, minute, second, millisecond } = this.getCurrentValue();
-
           // Convert to a valid time string according to:
           // https://html.spec.whatwg.org/multipage/infrastructure.html#valid-time-string
           if (this.mHour12) {
-            let dayPeriod = this.getDayPeriodValue();
             if (dayPeriod == this.mPMIndicator && hour < this.mMaxHour) {
               hour += this.mMaxHour;
             } else if (dayPeriod == this.mAMIndicator &&
                        hour == this.mMaxHour) {
               hour = 0;
             }
           }
 
@@ -949,17 +938,18 @@
           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");
-            if (buffer.length >= targetField.maxLength || n * 10 > max) {
+            let maxLength = targetField.getAttribute("maxLength");
+            if (buffer.length >= maxLength || n * 10 > max) {
               buffer = "";
               this.advanceToNextField();
             }
             targetField.setAttribute("typeBuffer", buffer);
           }
         ]]>
         </body>
       </method>
@@ -978,71 +968,66 @@
             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.
-              if (value == 0 && aValue.length == aField.maxLength) {
+              let maxLength = aField.getAttribute("maxlength");
+              if (value == 0 && aValue.length == maxLength) {
                 value = this.mMaxHour;
               } else {
                 value = (value > this.mMaxHour) ? value % this.mMaxHour : value;
               }
             } else if (value > this.mMaxHour) {
               value = this.mMaxHour;
             }
           }
 
           aField.setAttribute("rawValue", value);
 
-          let minDigits = aField.size;
+          let minDigits = aField.getAttribute("mindigits");
           let formatted = value.toLocaleString(this.mLocales, {
             minimumIntegerDigits: minDigits,
             useGrouping: false
           });
 
-          aField.value = formatted;
-          if (document.activeElement == this.mInputElement &&
-              this.mLastFocusedField && this.mLastFocusedField == aField) {
-            aField.select();
-          }
+          aField.textContent = formatted;
           this.updateResetButtonVisibility();
         ]]>
         </body>
       </method>
 
       <method name="getDayPeriodValue">
         <parameter name="aValue"/>
         <body>
         <![CDATA[
           if (!this.mDayPeriodField) {
             return "";
           }
 
-          return this.mDayPeriodField.value;
+          let placeholder = this.mDayPeriodField.placeholder;
+          let value = this.mDayPeriodField.textContent;
+
+          return (value == placeholder ? "" : value);
         ]]>
         </body>
       </method>
 
       <method name="setDayPeriodValue">
         <parameter name="aValue"/>
         <body>
         <![CDATA[
           if (!this.mDayPeriodField) {
             return;
           }
 
-          this.mDayPeriodField.value = aValue;
-          if (document.activeElement == this.mInputElement &&
-              this.mLastFocusedField &&
-              this.mLastFocusedField == this.mDayPeriodField) {
-            this.mDayPeriodField.select();
-          }
+          this.mDayPeriodField.textContent = aValue;
           this.updateResetButtonVisibility();
         ]]>
         </body>
       </method>
 
       <method name="isAnyValueAvailable">
         <parameter name="aForPicker"/>
         <body>
@@ -1175,49 +1160,57 @@
             dump("[DateTimeBox] " + aMsg + "\n");
           }
         ]]>
         </body>
       </method>
 
       <method name="createEditField">
         <parameter name="aPlaceHolder"/>
-        <parameter name="aLength"/>
         <parameter name="aIsNumeric"/>
-        <parameter name="aMin"/>
-        <parameter name="aMax"/>
+        <parameter name="aMinDigits"/>
+        <parameter name="aMaxLength"/>
+        <parameter name="aMinValue"/>
+        <parameter name="aMaxValue"/>
         <parameter name="aPageUpDownInterval"/>
         <body>
         <![CDATA[
           const HTML_NS = "http://www.w3.org/1999/xhtml";
 
-          let field = document.createElementNS(HTML_NS, "input");
-          field.classList.add("textbox-input", "datetime-input");
+          let field = document.createElementNS(HTML_NS, "span");
+          field.classList.add("datetime-edit-field");
+          field.textContent = aPlaceHolder;
+          field.placeholder = aPlaceHolder;
+          field.tabIndex = this.mInputElement.tabIndex;
+
+          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;
-          field.tabIndex = this.mInputElement.tabIndex;
-          field.placeholder = aPlaceHolder;
-
-          field.setAttribute("size", aLength);
-          field.setAttribute("maxlength", aLength);
 
           if (aIsNumeric) {
             field.classList.add("numeric");
             // Maximum value allowed.
-            field.setAttribute("min", aMin);
+            field.setAttribute("min", aMinValue);
             // Minumim value allowed.
-            field.setAttribute("max", aMax);
+            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", "");
             // Used to store the non-formatted number, clered when value is
             // cleared.
             field.setAttribute("rawValue", "");
+            // 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);
           }
 
           return field;
         ]]>
         </body>
       </method>
 
       <method name="updateResetButtonVisibility">
@@ -1236,17 +1229,18 @@
         <body>
         <![CDATA[
           this.log("focusInnerTextBox");
 
           let editRoot =
             document.getAnonymousElementByAttribute(this, "anonid", "edit-wrapper");
 
           for (let child = editRoot.firstChild; child; child = child.nextSibling) {
-            if (child instanceof HTMLInputElement) {
+            if ((child instanceof HTMLSpanElement) &&
+                child.classList.contains("datetime-edit-field")) {
               child.focus();
               break;
             }
           }
         ]]>
         </body>
       </method>
 
@@ -1289,17 +1283,18 @@
           let next = aReverse ? focusedInput.previousElementSibling
                               : focusedInput.nextElementSibling;
           if (!next && !aReverse) {
             this.setInputValueFromFields();
             return;
           }
 
           while (next) {
-            if (next.type == "text" && !next.disabled) {
+            if ((next instanceof HTMLSpanElement) &&
+                next.classList.contains("datetime-edit-field")) {
               next.focus();
               break;
             }
             next = aReverse ? next.previousElementSibling
                             : next.nextElementSibling;
           }
         ]]>
         </body>
@@ -1326,18 +1321,36 @@
               aName != "readonly") {
             return;
           }
 
           let editRoot =
             document.getAnonymousElementByAttribute(this, "anonid", "edit-wrapper");
 
           for (let child = editRoot.firstChild; child; child = child.nextSibling) {
-            if (child instanceof HTMLInputElement) {
-              child.setAttribute(aName, aValue);
+            if ((child instanceof HTMLSpanElement) &&
+                child.classList.contains("datetime-edit-field")) {
+
+              switch (aName) {
+                case "tabindex":
+                  child.setAttribute(aName, aValue);
+                  break;
+                case "disabled": {
+                  let value = this.mInputElement.disabled;
+                  child.setAttribute("disabled", value);
+                  child.disabled = value;
+                  break;
+                }
+                case "readonly": {
+                  let value = this.mInputElement.readOnly;
+                  child.setAttribute("readonly", value);
+                  child.readOnly = value;
+                  break;
+                }
+              }
             }
           }
         ]]>
         </body>
       </method>
 
       <method name="removeEditAttribute">
         <parameter name="aName"/>
@@ -1349,18 +1362,25 @@
               aName != "readonly") {
             return;
           }
 
           let editRoot =
             document.getAnonymousElementByAttribute(this, "anonid", "edit-wrapper");
 
           for (let child = editRoot.firstChild; child; child = child.nextSibling) {
-            if (child instanceof HTMLInputElement) {
+            if ((child instanceof HTMLSpanElement) &&
+                child.classList.contains("datetime-edit-field")) {
               child.removeAttribute(aName);
+              // Update property as well.
+              if (aName == "readonly") {
+                child.readOnly = false;
+              } else if (aName == "disabled") {
+                child.disabled = false;
+              }
             }
           }
         ]]>
         </body>
       </method>
 
       <method name="isEmpty">
         <parameter name="aValue"/>
@@ -1397,17 +1417,17 @@
         ]]>
         </body>
       </method>
 
       <method name="clearFieldValue">
         <parameter name="aField"/>
         <body>
         <![CDATA[
-          aField.value = "";
+          aField.textContent = aField.placeholder;
           if (aField.classList.contains("numeric")) {
             aField.setAttribute("typeBuffer", "");
             aField.setAttribute("rawValue", "");
           }
           this.updateResetButtonVisibility();
         ]]>
         </body>
       </method>
@@ -1530,19 +1550,22 @@
 
       <method name="onFocus">
         <parameter name="aEvent"/>
         <body>
         <![CDATA[
           this.log("onFocus originalTarget: " + aEvent.originalTarget);
 
           let target = aEvent.originalTarget;
-          if ((target instanceof HTMLInputElement) && target.type == "text") {
+          if ((target instanceof HTMLSpanElement) &&
+              target.classList.contains("datetime-edit-field")) {
+            if (target.disabled) {
+              return;
+            }
             this.mLastFocusedField = target;
-            target.select();
           }
         ]]>
         </body>
       </method>
 
       <method name="onBlur">
         <parameter name="aEvent"/>
         <body>