Bug 1612765 - Make Extra Recipients Panel keyboard accessible. r=aleca, ui-r=Paenglab
authorThomas Duellmann <bugzilla2007@duellmann24.net>
Fri, 14 Feb 2020 12:22:13 +0200
changeset 28765 5da5f8058391a95b5f425a13d4403e97ee0f0216
parent 28764 027b7fafc431c9cfd396f1b16235d47819b3ae2a
child 28766 591b7de150d5980316e2a368e30dd14a30779cff
push id17024
push usermkmelin@iki.fi
push dateFri, 14 Feb 2020 10:24:52 +0000
treeherdercomm-central@591b7de150d5 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersaleca, Paenglab
bugs1612765
Bug 1612765 - Make Extra Recipients Panel keyboard accessible. r=aleca, ui-r=Paenglab
mail/components/compose/content/MsgComposeCommands.js
mail/components/compose/content/addressingWidgetOverlay.js
mail/components/compose/content/messengercompose.xhtml
mail/themes/shared/mail/messengercompose.css
--- a/mail/components/compose/content/MsgComposeCommands.js
+++ b/mail/components/compose/content/MsgComposeCommands.js
@@ -3420,16 +3420,17 @@ function ComposeLoad() {
 
     if (otherHeaders) {
       let extraRecipientsPanel = document.getElementById(
         "extraRecipientsPanel"
       );
       let recipientsContainer = document.getElementById("recipientsContainer");
 
       for (let header of otherHeaders.split(",")) {
+        header = header.trim();
         let recipient = {
           id: `${header}AddrInput`,
           row: `addressRow${header}`,
           label: `${header}AddrLabel`,
           labelId: header,
           container: `${header}AddrContainer`,
           class: "news-input",
           type: "addr_other",
@@ -3565,36 +3566,37 @@ function updateTooltipsOfAddressingField
   let type = row.querySelector(".address-label-container > label").value;
   let tooltip = l10n.formatValueSync("remove-address-row-type", { type });
   row
     .querySelector(".aw-firstColBox > label")
     .setAttribute("tooltiptext", tooltip);
 }
 
 /**
- * Create the recipient label to add in the messenger compose dialog.
+ * Create a custom recipient label to add in the compose window.
  *
- * @param {string} labelID - The unique identifier of the email header.
- * @returns {XULelement} The newly create XUL label.
+ * @param {string} labelID - The unique identifier of the custom email header.
+ * @returns {Element} The newly created label.
  */
 function createRecipientLabel(labelID) {
   let label = document.createXULElement("label");
   label.setAttribute("id", labelID);
+  label.classList.add("recipient-label");
+  label.setAttribute("role", "button");
   label.setAttribute("value", labelID);
-  label.setAttribute("role", "button");
 
   label.addEventListener("click", () => {
     showAddressRow(label, `addressRow${labelID}`);
   });
   label.addEventListener("keypress", event => {
-    showAddressRowKeyPress(event, label, `addressRow${labelID}`);
+    showAddressRowKeyPress(event, `addressRow${labelID}`);
   });
   label.setAttribute("control", `${labelID}AddrInput`);
 
-  // Necessary to allow focus via TAB key.
+  // Necessary to allow focus via TAB key or cursor keys.
   label.setAttribute("tabindex", 0);
 
   return label;
 }
 
 /**
  * Return the full display string for any non-default text encoding of the
  * current composition (friendly name plus official character set name).
--- a/mail/components/compose/content/addressingWidgetOverlay.js
+++ b/mail/components/compose/content/addressingWidgetOverlay.js
@@ -733,26 +733,55 @@ function cutEmailNewsAddress(element) {
  */
 function deleteAddressPill(element) {
   // element is the pill's <label>, get the pill.
   let pill = element.closest("mail-address-pill");
   document.getElementById("recipientsContainer").removeSelectedPills(pill);
 }
 
 /**
- * Handle the keypress event on the labels to show the container row
- * of an hidden recipient (Cc, Bcc, etc.).
+ * Handle the keypress event on the recipient labels for keyboard navigation and
+ * to show the container row of a hidden recipient (Cc, Bcc, etc.).
  *
  * @param {Event} event - The DOM keypress event.
- * @param {XULelement} label - The clicked label to hide.
- * @param {string} rowID - The ID of the container to reveal.
+ * @param {string} rowID - The ID of the container to reveal on Enter.
  */
-function showAddressRowKeyPress(event, label, rowID) {
-  if (event.key == "Enter") {
-    showAddressRow(label, rowID);
+function showAddressRowKeyPress(event, rowID) {
+  switch (event.key) {
+    case "Enter":
+      showAddressRow(event.target, rowID);
+      break;
+    case "ArrowUp":
+    case "ArrowDown":
+    case "ArrowRight":
+    case "ArrowLeft":
+      let label = event.target;
+      // Convert nodelist into an array to tame the beast and use .indexOf().
+      let focusable = [
+        ...label.parentElement.querySelectorAll(
+          ".recipient-label:not([collapsed='true'])"
+        ),
+      ];
+      let lastIndex = focusable.length - 1;
+      // Bail out if there's only one item left, so nowhere to go with focus.
+      if (lastIndex == 0) {
+        break;
+      }
+      // Move focus inside the panel focus ring.
+      let index = focusable.indexOf(label);
+      let newIndex;
+      if (event.key == "ArrowDown" || event.key == "ArrowRight") {
+        newIndex = index == lastIndex ? 0 : ++index;
+      } else {
+        newIndex = index == 0 ? lastIndex : --index;
+      }
+      focusable[newIndex].focus();
+      // Prevent the keys from being handled again by our listeners on the panel.
+      event.stopPropagation();
+      break;
   }
 }
 
 /**
  * Show the container row of an hidden recipient (Cc, Bcc, etc.).
  *
  * @param {XULElement} label - The clicked label to hide.
  * @param {string} rowID - The ID of the container to reveal.
@@ -899,26 +928,94 @@ function calculateHeaderHeight() {
 
     if (!header.hasAttribute("height")) {
       header.setAttribute("height", 300);
     }
   }
 }
 
 /**
+ * Handle keypress event on a label inside #extraRecipientsPanel.
+ *
+ * @param {event} event - The DOM keypress event on the label.
+ */
+function extraRecipientsLabelOnKeyPress(event) {
+  switch (event.key) {
+    case "Enter":
+    case "ArrowRight":
+    case "ArrowDown":
+      // Open the extra recipients panel.
+      showExtraRecipients(event);
+      break;
+    case "ArrowLeft":
+    case "ArrowUp":
+      // Allow navigating away from focused extraRecipientsLabel using cursor
+      // keys.
+      let focusable = event.currentTarget.parentElement.querySelectorAll(
+        '.recipient-label:not([collapsed="true"]):not(.extra-recipients-label)'
+      );
+      let focusEl = focusable[focusable.length - 1];
+      if (focusEl) {
+        focusEl.focus();
+      }
+      break;
+  }
+}
+
+/**
  * Show the #extraRecipientsPanel.
  *
  * @param {Event} event - The DOM event.
  */
 function showExtraRecipients(event) {
   let panel = document.getElementById("extraRecipientsPanel");
+  // If panel was opened with keyboard, focus first recipient label;
+  // otherwise focus the panel [tabindex=0] to enable keyboard navigation.
+  panel.addEventListener(
+    "popupshown",
+    () => {
+      (event.type == "keypress"
+        ? panel.querySelector('.recipient-label:not([collapsed="true"])')
+        : panel
+      ).focus();
+    },
+    { once: true }
+  );
   panel.openPopup(event.originalTarget, "after_end", -8, 0, true);
 }
 
 /**
+ * Handle keypress event on #extraRecipientsPanel.
+ *
+ * @param {event} event - The DOM keypress event on the panel.
+ */
+function extraRecipientsPanelOnKeyPress(event) {
+  switch (event.key) {
+    case "Enter":
+      event.currentTarget.hidePopup();
+      break;
+
+    // Ensure access to panel focus ring after *click* on extraRecipientsLabel.
+    case "ArrowDown":
+      // Focus first focusable recipient label.
+      event.currentTarget
+        .querySelector('.recipient-label:not([collapsed="true"])')
+        .focus();
+      break;
+    case "ArrowUp":
+      // Focus last focusable recipient label.
+      let focusable = event.currentTarget.querySelectorAll(
+        '.recipient-label:not([collapsed="true"])'
+      );
+      focusable[focusable.length - 1].focus();
+      break;
+  }
+}
+
+/**
  * Hide or show the panel and overflow button for the extra recipients
  * based on the currently available labels.
  */
 function updateRecipientsPanelVisibility() {
   document.getElementById("extraRecipientsLabel").collapsed =
     document
       .getElementById("extraRecipientsPanel")
       .querySelectorAll('label:not([collapsed="true"])').length == 0;
--- a/mail/components/compose/content/messengercompose.xhtml
+++ b/mail/components/compose/content/messengercompose.xhtml
@@ -587,30 +587,34 @@
                  label-ZA="&sortAttachmentsPanelBtn.Sort.ZA.label;"
                  label-selection-AZ="&sortAttachmentsPanelBtn.SortSelection.AZ.label;"
                  label-selection-ZA="&sortAttachmentsPanelBtn.SortSelection.ZA.label;"
                  key="key_sortAttachmentsToggle"
                  command="cmd_sortAttachmentsToggle"/>
 </panel>
 
 <panel id="extraRecipientsPanel" type="arrow" orient="vertical"
-       onkeypress="if (event.key == 'Enter') { this.hidePopup(); }"
+       tabindex="0"
+       onkeypress="extraRecipientsPanelOnKeyPress(event);"
        onclick="this.hidePopup();">
   <label id="addr_reply" value="&replyAddr2.label;" role="button"
+         class="recipient-label"
          onclick="showAddressRow(this, 'addressRowReply')"
-         onkeypress="showAddressRowKeyPress(event, this, 'addressRowReply')"
-         control="replyAddrInput" class="recipient-label"/>
+         onkeypress="showAddressRowKeyPress(event, 'addressRowReply')"
+         control="replyAddrInput"/>
   <label id="addr_newsgroups" value="&newsgroupsAddr2.label;" role="button"
+         class="news-label news-primary-label recipient-label"
          onclick="showAddressRow(this, 'addressRowNewsgroups')"
-         onkeypress="showAddressRowKeyPress(event, this, 'addressRowNewsgroups')"
-         control="newsgroupsAddrInput" class="news-label news-primary-label recipient-label"/>
+         onkeypress="showAddressRowKeyPress(event, 'addressRowNewsgroups')"
+         control="newsgroupsAddrInput"/>
   <label id="addr_followup" value="&followupAddr2.label;" role="button"
+         class="news-label recipient-label"
          onclick="showAddressRow(this, 'addressRowFollowup')"
-         onkeypress="showAddressRowKeyPress(event, this, 'addressRowFollowup')"
-         control="followupAddrInput" class="news-label recipient-label"/>
+         onkeypress="showAddressRowKeyPress(event, 'addressRowFollowup')"
+         control="followupAddrInput"/>
 </panel>
 
 <menupopup id="msgComposeContext"
            onpopupshowing="if (event.target != this) { return true; } openEditorContextMenu(this);">
 
   <!-- Spellchecking menu items -->
   <menuitem id="spellCheckNoSuggestions" label="&spellNoSuggestions.label;" disabled="true"/>
   <menuseparator id="spellCheckAddSep" />
@@ -2027,46 +2031,49 @@
                       oncommand="LoadIdentity(false);" disableonsend="true">
               <menupopup id="msgIdentityPopup"/>
             </menulist>
 
             <hbox class="addressingWidgetItem">
               <hbox id="addressingWidgetLabels" class="address-extra-recipients"
                     flex="1" align="center">
                 <label id="addr_to" value="&toAddr2.label;" role="button"
+                       class="mail-primary-label mail-label recipient-label"
                        onclick="showAddressRow(this, 'addressRowTo')"
-                       onkeypress="showAddressRowKeyPress(event, this, 'addressRowTo')"
-                       control="toAddrInput" class="mail-primary-label mail-label recipient-label"
+                       onkeypress="showAddressRowKeyPress(event, 'addressRowTo')"
+                       control="toAddrInput"
                        collapsed="true"/>
                 <label id="addr_cc" value="&ccAddr2.label;" role="button"
+                       class="mail-label recipient-label"
                        onclick="showAddressRow(this, 'addressRowCc')"
-                       onkeypress="showAddressRowKeyPress(event, this, 'addressRowCc')"
-                       control="ccAddrInput" class="mail-label recipient-label"/>
+                       onkeypress="showAddressRowKeyPress(event, 'addressRowCc')"
+                       control="ccAddrInput"/>
                 <label id="addr_bcc" value="&bccAddr2.label;" role="button"
+                       class="mail-label recipient-label"
                        onclick="showAddressRow(this, 'addressRowBcc')"
-                       onkeypress="showAddressRowKeyPress(event, this, 'addressRowBcc')"
-                       control="bccAddrInput" class="mail-label recipient-label"/>
-
+                       onkeypress="showAddressRowKeyPress(event, 'addressRowBcc')"
+                       control="bccAddrInput"/>
                 <label id="extraRecipientsLabel" role="button"
+                       class="extra-recipients-label recipient-label"
                        onclick="showExtraRecipients(event);"
                        tooltiptext="&extraRecipients.tooltip;"
-                       class="extra-recipients-label"
-                       onkeypress="if (event.key == 'Enter') { showExtraRecipients(event); }">
+                       onkeypress="extraRecipientsLabelOnKeyPress(event);">
                     <image class="overflow-icon"/>
                 </label>
 
               </hbox>
             </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 role="button" onclick="hideAddressRow(this, 'addr_to');"
+                <label role="button"
+                       onclick="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;"
@@ -2093,17 +2100,18 @@
                             onkeypress="recipientKeyPress(event, this);"
                             recipienttype="addr_to"
                             size="1"/>
               </hbox>
             </hbox>
 
             <hbox id="addressRowCc" class="addressingWidgetItem address-row hidden">
               <hbox class="aw-firstColBox" align="top">
-                <label role="button" onclick="hideAddressRow(this, 'addr_cc');"
+                <label role="button"
+                       onclick="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"/>
@@ -2129,17 +2137,18 @@
                             onkeypress="recipientKeyPress(event, this);"
                             recipienttype="addr_cc"
                             size="1"/>
               </hbox>
             </hbox>
 
             <hbox id="addressRowBcc" class="addressingWidgetItem address-row hidden">
               <hbox class="aw-firstColBox" align="top">
-                <label role="button" onclick="hideAddressRow(this, 'addr_bcc');"
+                <label role="button"
+                       onclick="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"/>
@@ -2165,17 +2174,18 @@
                             onkeypress="recipientKeyPress(event, this);"
                             recipienttype="addr_bcc"
                             size="1"/>
               </hbox>
             </hbox>
 
             <hbox id="addressRowReply" class="addressingWidgetItem address-row hidden">
               <hbox class="aw-firstColBox" align="top">
-                <label role="button" onclick="hideAddressRow(this, 'addr_reply');"
+                <label role="button"
+                       onclick="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"/>
@@ -2201,17 +2211,18 @@
                             onkeypress="recipientKeyPress(event, this);"
                             recipienttype="addr_reply"
                             size="1"/>
               </hbox>
             </hbox>
 
             <hbox id="addressRowNewsgroups" class="addressingWidgetItem address-row hidden">
               <hbox class="aw-firstColBox" align="top">
-                <label role="button" onclick="hideAddressRow(this, 'addr_newsgroups');"
+                <label role="button"
+                       onclick="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"/>
@@ -2237,17 +2248,18 @@
                             onkeypress="recipientKeyPress(event, this);"
                             recipienttype="addr_newsgroups"
                             size="1"/>
               </hbox>
             </hbox>
 
             <hbox id="addressRowFollowup" class="addressingWidgetItem address-row hidden">
               <hbox class="aw-firstColBox" align="top">
-                <label role="button" onclick="hideAddressRow(this, 'addr_followup');"
+                <label role="button"
+                       onclick="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"/>
--- a/mail/themes/shared/mail/messengercompose.css
+++ b/mail/themes/shared/mail/messengercompose.css
@@ -659,23 +659,24 @@ label.extra-recipients-label {
 
 #extraRecipientsPanel {
   min-width: 160px;
   --arrowpanel-padding: 0;
 }
 
 #extraRecipientsPanel label {
   padding: 4px 8px;
-  margin: 2px 0;
+  margin: 0;
   width: 100%;
   cursor: pointer;
   transition: background-color 0.2s;
 }
 
-#extraRecipientsPanel label:hover {
+#extraRecipientsPanel label:hover,
+#extraRecipientsPanel label:focus {
   background-color: var(--arrowpanel-dimmed);
 }
 
 .aw-firstColBox label:hover .close-icon {
   fill-opacity: 0.1;
 }
 
 .aw-firstColBox label {