Bug 1602372 - Move focus ring through compose input fields on Tab keypress. r=mkmelin
authorAlessandro Castellani <alessandro@thunderbird.net>
Wed, 29 Jan 2020 12:22:57 -0800
changeset 38040 fefcbe1ff081081f1c99e7a0549889a3e24a7e0d
parent 38039 228c1b31448af4c5bf8848bfb5ae50eb1d23b068
child 38041 91f77a3708ffa978462e80a87764ca364ad669f6
push id398
push userclokep@gmail.com
push dateMon, 09 Mar 2020 19:10:28 +0000
reviewersmkmelin
bugs1602372
Bug 1602372 - Move focus ring through compose input fields on Tab keypress. r=mkmelin
mail/base/content/mailWidgets.js
mail/components/compose/content/MsgComposeCommands.js
mail/components/compose/content/addressingWidgetOverlay.js
mail/components/compose/content/messengercompose.xhtml
--- a/mail/base/content/mailWidgets.js
+++ b/mail/base/content/mailWidgets.js
@@ -2018,17 +2018,45 @@
       this.hasConnected = true;
 
       this.highlightNonMatches = Services.prefs.getBoolPref(
         "mail.autoComplete.highlightNonMatches"
       );
 
       for (let input of this.querySelectorAll(".mail-input,.news-input")) {
         setupAutocompleteInput(input, this.highlightNonMatches);
+
+        input.addEventListener("keypress", event => {
+          if (event.key != "Tab" || !event.shiftKey) {
+            return;
+          }
+          event.preventDefault();
+          this.moveFocusToPreviousElement(input);
+        });
       }
+
+      // Force the focus on the first available input field if Tab is
+      // pressed on the extraRecipientsLabel label.
+      document
+        .getElementById("extraRecipientsLabel")
+        .addEventListener("keypress", event => {
+          if (event.key == "Tab" && !event.shiftKey) {
+            event.preventDefault();
+            let row = this.querySelector(".address-row:not(.hidden)");
+            // If the close label is collpased, focus on the input field.
+            if (row.querySelector(".aw-firstColBox > label").collapsed) {
+              row
+                .querySelector(`input[is="autocomplete-input"][recipienttype]`)
+                .focus();
+              return;
+            }
+            // Focus on the close label.
+            row.querySelector(".aw-firstColBox > label").focus();
+          }
+        });
     }
 
     /**
      * Create a new recipient row container with the input autocomplete.
      *
      * @param {Array} recipient - The unique identifier of the email header.
      * @return {Element} - The newly created recipient row.
      */
@@ -2044,19 +2072,17 @@
 
       let firstLabel = document.createXULElement("label");
       let labelId =
         recipient.type == "addr_other" ? recipient.labelId : recipient.type;
       firstLabel.addEventListener("click", () => {
         hideAddressRow(firstLabel, labelId);
       });
       firstLabel.addEventListener("keypress", event => {
-        if (event.key == "Enter") {
-          hideAddressRow(firstLabel, labelId);
-        }
+        closeLabelKeyPress(event, firstLabel, labelId);
       });
       // Necessary to allow focus via TAB key.
       firstLabel.setAttribute("tabindex", 0);
 
       let closeImage = document.createXULElement("image");
       closeImage.classList.add("close-icon");
 
       firstLabel.appendChild(closeImage);
@@ -2094,16 +2120,17 @@
       inputContainer.addEventListener("click", focusAddressInput);
 
       let input = document.createElement("input", {
         is: "autocomplete-input",
       });
       input.setAttribute("id", recipient.id);
 
       input.setAttribute("type", "text");
+      input.setAttribute("aria-labelledby", recipient.labelId);
       input.classList.add("plain", "address-input", recipient.class);
       input.setAttribute("disableonsend", true);
       input.setAttribute("autocompletesearch", "mydomain addrbook ldap news");
       input.setAttribute("autocompletesearchparam", "{}");
       input.setAttribute("timeout", 300);
       input.setAttribute("maxrows", 6);
       input.setAttribute("completedefaultindex", true);
       input.setAttribute("forcecomplete", true);
@@ -2114,16 +2141,21 @@
       input.addEventListener("focus", () => {
         highlightAddressContainer(input);
       });
       input.addEventListener("blur", () => {
         resetAddressContainer(input);
       });
       input.addEventListener("keypress", event => {
         recipientKeyPress(event, input);
+        if (event.key != "Tab" || !event.shiftKey) {
+          return;
+        }
+        event.preventDefault();
+        this.moveFocusToPreviousElement(input);
       });
 
       input.setAttribute("recipienttype", recipient.type);
       input.setAttribute("size", 1);
 
       setupAutocompleteInput(input, this.highlightNonMatches);
 
       inputContainer.appendChild(input);
@@ -2268,19 +2300,25 @@
           this.setFocusOnFirstPill(pill);
           break;
 
         case "End":
           pill.rowInput.focus();
           break;
 
         case "Tab":
+          event.preventDefault();
           for (let item of this.getSiblingPills(pill)) {
             item.removeAttribute("selected");
           }
+          if (event.shiftKey) {
+            this.moveFocusToPreviousElement(pill);
+            return;
+          }
+          pill.rowInput.focus();
           break;
 
         case "a":
           if (event.ctrlKey || event.metaKey) {
             this.selectPills(pill);
           }
           break;
 
@@ -2485,12 +2523,53 @@
     /**
      * Check if any pill in the addressing area is selected.
      *
      * @return {boolean} true if any pill is selected.
      */
     hasSelectedPills() {
       return Boolean(this.querySelector("mail-address-pill[selected]"));
     }
+
+    /**
+     * Move the focus to the previous focusable element.
+     *
+     * @param {Element} element - The element where the event was triggered.
+     */
+    moveFocusToPreviousElement(element) {
+      let row = element.closest(".address-row");
+      // Move focus on the close label if not collapsed.
+      if (!row.querySelector(".aw-firstColBox > label").collapsed) {
+        row.querySelector(".aw-firstColBox > label").focus();
+        return;
+      }
+      // If a previous address row is available and not hidden,
+      // focus on the autocomplete input field.
+      let previousRow = row.previousElementSibling;
+      while (previousRow) {
+        if (!previousRow.classList.contains("hidden")) {
+          previousRow
+            .querySelector(`input[is="autocomplete-input"][recipienttype]`)
+            .focus();
+          return;
+        }
+        previousRow = previousRow.previousElementSibling;
+      }
+      // Move the focus on the extra recipients label if not collapsed
+      if (!document.querySelector(".extra-recipients-label").collapsed) {
+        document.querySelector(".extra-recipients-label").focus();
+        return;
+      }
+      // Move the focus on the msgIdentity if no extra recipients are available.
+      let labels = document
+        .querySelector(".address-extra-recipients")
+        .querySelectorAll(`label:not([collapsed="true"])`);
+      if (labels.length == 0) {
+        document.getElementById("msgIdentity").focus();
+        return;
+      }
+      // Select the last available label.
+      labels[labels.length - 1].focus();
+    }
   }
 
   customElements.define("mail-recipients-area", MailRecipientsArea);
 }
--- a/mail/components/compose/content/MsgComposeCommands.js
+++ b/mail/components/compose/content/MsgComposeCommands.js
@@ -6513,16 +6513,50 @@ function setupAutocompleteInput(input, h
   // Request that input that isn't matched be highlighted.
   input.highlightNonMatches = highlightNonMatches;
 }
 
 function fromKeyPress(event) {
   if (event.keyCode == KeyEvent.DOM_VK_RETURN) {
     document.getElementById("toAddrInput").focus();
   }
+
+  // Interrupt if it's not a Tab event or the shift key was pressed.
+  if (event.key != "Tab" || event.shiftKey) {
+    return;
+  }
+
+  // If extra labels are available, let the focus move normally.
+  if (
+    document
+      .getElementById("addressingWidgetLabels")
+      .querySelectorAll(`label:not([collapsed="true"])`).length > 0
+  ) {
+    return;
+  }
+
+  // If the extra recipients label is visible, let the focus move normally.
+  if (!document.getElementById("extraRecipientsLabel").collapsed) {
+    return;
+  }
+
+  event.preventDefault();
+
+  let row = document
+    .getElementById("recipientsContainer")
+    .querySelector(".address-row:not(.hidden)");
+
+  // Move focus on the close label if not collapsed.
+  if (!row.querySelector(".aw-firstColBox > label").collapsed) {
+    row.querySelector(".aw-firstColBox > label").focus();
+    return;
+  }
+
+  // Focus on the autocomplete input field.
+  row.querySelector(`input[is="autocomplete-input"][recipienttype]`).focus();
 }
 
 function subjectKeyPress(event) {
   gSubjectChanged = true;
   if (event.keyCode == KeyEvent.DOM_VK_RETURN) {
     SetMsgBodyFrameFocus();
   }
 }
--- a/mail/components/compose/content/addressingWidgetOverlay.js
+++ b/mail/components/compose/content/addressingWidgetOverlay.js
@@ -767,16 +767,55 @@ function hideAddressRow(element, labelID
   input.value = "";
 
   container.classList.add("hidden");
   document.getElementById(labelID).removeAttribute("collapsed");
 
   // Update the sender button only if pills were deleted.
   onRecipientsChanged(!pills.length);
   updateRecipientsPanelVisibility();
+
+  // Move focus to the next focusable autocomplete input field.
+  if (
+    container.nextElementSibling &&
+    !container.nextElementSibling.classList.contains("hidden")
+  ) {
+    container.nextElementSibling
+      .querySelector(`input[is="autocomplete-input"][recipienttype]`)
+      .focus();
+    return;
+  }
+  // Move focus to the subject field.
+  document.getElementById("msgSubject").focus();
+}
+
+/**
+ * Handle the keypress event on the close label of a recipient row.
+ *
+ * @param {Event} event - The DOM Event.
+ * @param {Element} element - The focused label.
+ * @param {string} labelID - The ID of the label to show.
+ */
+function closeLabelKeyPress(event, element, labelID) {
+  switch (event.key) {
+    case "Enter":
+      hideAddressRow(element, labelID);
+      break;
+
+    case "Tab":
+      if (event.shiftKey) {
+        return;
+      }
+      event.preventDefault();
+      element
+        .closest(".address-row")
+        .querySelector(`input[is="autocomplete-input"][recipienttype]`)
+        .focus();
+      break;
+  }
 }
 
 /**
  * Calculate the height of the composer header area every time a pill is created.
  * If the height is bigger than 2/3 of the compose window heigh, enable overflow.
  */
 function calculateHeaderHeight() {
   let container = document.getElementById("recipientsContainer");
--- a/mail/components/compose/content/messengercompose.xhtml
+++ b/mail/components/compose/content/messengercompose.xhtml
@@ -2057,17 +2057,17 @@
             </hbox>
           </hbox>
 
           <mail-recipients-area id="recipientsContainer" orient="vertical"
                                 class="recipients-container" flex="1">
             <hbox id="addressRowTo" class="addressingWidgetItem address-row">
               <hbox class="aw-firstColBox" align="top">
                 <label onclick="hideAddressRow(this, 'addr_to');"
-                       onkeypress="if (event.key == 'Enter') { hideAddressRow(this, 'addr_to'); }"
+                       onkeypress="closeLabelKeyPress(event, this, 'addr_to');"
                        collapsed="true">
                   <image class="close-icon"/>
                 </label>
               </hbox>
               <hbox class="address-label-container" align="top" pack="end"
                     style="&headersSpace2.style;">
                 <label id="toAddrLabel" value="&toAddr2.label;"
                        control="toAddrInput"/>
@@ -2095,17 +2095,17 @@
                             recipienttype="addr_to"
                             size="1"/>
               </hbox>
             </hbox>
 
             <hbox id="addressRowCc" class="addressingWidgetItem address-row hidden">
               <hbox class="aw-firstColBox" align="top">
                 <label onclick="hideAddressRow(this, 'addr_cc');"
-                       onkeypress="if (event.key == 'Enter') { hideAddressRow(this, 'addr_cc'); }">
+                       onkeypress="closeLabelKeyPress(event, this, 'addr_cc');">
                   <image class="close-icon"/>
                 </label>
               </hbox>
               <hbox class="address-label-container" align="top" pack="end"
                     style="&headersSpace2.style;">
                 <label id="ccAddrLabel" value="&ccAddr2.label;"
                        control="ccAddrInput"/>
               </hbox>
@@ -2132,17 +2132,17 @@
                             recipienttype="addr_cc"
                             size="1"/>
               </hbox>
             </hbox>
 
             <hbox id="addressRowBcc" class="addressingWidgetItem address-row hidden">
               <hbox class="aw-firstColBox" align="top">
                 <label onclick="hideAddressRow(this, 'addr_bcc');"
-                       onkeypress="if (event.key == 'Enter') { hideAddressRow(this, 'addr_bcc'); }">
+                       onkeypress="closeLabelKeyPress(event, this, 'addr_bcc');">
                   <image class="close-icon"/>
                 </label>
               </hbox>
               <hbox class="address-label-container" align="top" pack="end"
                     style="&headersSpace2.style;">
                 <label id="bccAddrLabel" value="&bccAddr2.label;"
                        control="bccAddrInput"/>
               </hbox>
@@ -2169,17 +2169,17 @@
                             recipienttype="addr_bcc"
                             size="1"/>
               </hbox>
             </hbox>
 
             <hbox id="addressRowReply" class="addressingWidgetItem address-row hidden">
               <hbox class="aw-firstColBox" align="top">
                 <label onclick="hideAddressRow(this, 'addr_reply');"
-                       onkeypress="if (event.key == 'Enter') { hideAddressRow(this, 'addr_reply'); }">
+                       onkeypress="closeLabelKeyPress(event, this, 'addr_reply');">
                   <image class="close-icon"/>
                 </label>
               </hbox>
               <hbox class="address-label-container" align="top" pack="end"
                     style="&headersSpace2.style;">
                 <label id="replyAddrLabel" value="&replyAddr2.label;"
                        control="replyAddrInput"/>
               </hbox>
@@ -2206,17 +2206,17 @@
                             recipienttype="addr_reply"
                             size="1"/>
               </hbox>
             </hbox>
 
             <hbox id="addressRowNewsgroups" class="addressingWidgetItem address-row hidden">
               <hbox class="aw-firstColBox" align="top">
                 <label onclick="hideAddressRow(this, 'addr_newsgroups');"
-                       onkeypress="if (event.key == 'Enter') { hideAddressRow(this, 'addr_newsgroups'); }">
+                       onkeypress="closeLabelKeyPress(event, this, 'addr_newsgroups');">
                   <image class="close-icon"/>
                 </label>
               </hbox>
               <hbox class="address-label-container" align="top" pack="end"
                     style="&headersSpace2.style;">
                 <label id="newsgroupsAddrLabel" value="&newsgroupsAddr2.label;"
                        control="newsgroupsAddrInput"/>
               </hbox>
@@ -2243,17 +2243,17 @@
                             recipienttype="addr_newsgroups"
                             size="1"/>
               </hbox>
             </hbox>
 
             <hbox id="addressRowFollowup" class="addressingWidgetItem address-row hidden">
               <hbox class="aw-firstColBox" align="top">
                 <label onclick="hideAddressRow(this, 'addr_followup');"
-                       onkeypress="if (event.key == 'Enter') { hideAddressRow(this, 'addr_followup'); }">
+                       onkeypress="closeLabelKeyPress(event, this, 'addr_followup');">
                   <image class="close-icon"/>
                 </label>
               </hbox>
               <hbox class="address-label-container" align="top" pack="end"
                     style="&headersSpace2.style;">
                 <label id="followupAddrLabel" value="&followupAddr2.label;"
                        control="followupAddrInput"/>
               </hbox>