Bug 1775245 - Use ARIA role of "tree" for the addressbook and account manager trees. r=aleca,darktrojan
authorHenry Wilkes <henry@thunderbird.net>
Mon, 04 Jul 2022 11:45:55 +0000
changeset 119340 2b88e08e67f3bdad31a50152dfb0386b6b461ebc
parent 119339 43e6ede7443c6fbd8053840bd869576fde5daaba
child 119341 bcda942b21599a20ca85cbe7d79b0e4a9cf1fea1
push id16221
push usergeoff@darktrojan.net
push dateMon, 04 Jul 2022 23:41:26 +0000
treeherdertry-comm-central@addcb197f566 [default view] [failures only]
reviewersaleca, darktrojan
bugs1775245
Bug 1775245 - Use ARIA role of "tree" for the addressbook and account manager trees. r=aleca,darktrojan Differential Revision: https://phabricator.services.mozilla.com/D149874
calendar/base/content/calendar-tab-panels.inc.xhtml
calendar/base/content/calendar-today-pane.inc.xhtml
mail/base/content/about3Pane.xhtml
mail/base/content/widgets/tree-listbox.js
mail/base/test/browser/files/orderableTreeListbox.xhtml
mail/base/test/browser/files/treeListbox.xhtml
mail/components/addrbook/content/aboutAddressBook.xhtml
mailnews/base/prefs/content/AccountManager.xhtml
--- a/calendar/base/content/calendar-tab-panels.inc.xhtml
+++ b/calendar/base/content/calendar-tab-panels.inc.xhtml
@@ -187,17 +187,19 @@
                            command="calendar_new_calendar_command"
                            tooltiptext="&calendar.context.newserver.label;"/>
           </hbox>
           <calendar-modevbox id="calendar-list-inner-pane"
                              flex="1"
                              mode="calendar,task"
                              refcontrol="calendar-list-header"
                              context="list-calendars-context-menu">
-            <html:ol id="calendar-list" is="orderable-tree-listbox"></html:ol>
+            <html:ol id="calendar-list" is="orderable-tree-listbox"
+                     role="listbox">
+            </html:ol>
             <template id="calendar-list-item" xmlns="http://www.w3.org/1999/xhtml">
               <li draggable="true" role="option">
                 <div class="calendar-color"></div>
                 <div class="calendar-name"></div>
                 <img class="calendar-readstatus" src="chrome://calendar/skin/shared/icons/locked.svg" />
                 <button class="calendar-enable-button"></button>
                 <input type="checkbox" class="calendar-displayed" />
               </li>
--- a/calendar/base/content/calendar-today-pane.inc.xhtml
+++ b/calendar/base/content/calendar-today-pane.inc.xhtml
@@ -127,17 +127,17 @@
           <toolbarbutton id="todaypane-new-event-button"
                          mode="mail"
                          iconsize="small"
                          orient="horizontal"
                          label="&calendar.newevent.button.label;"
                          tooltiptext="&calendar.newevent.button.tooltip;"
                          command="calendar_new_event_command"/>
         </hbox>
-        <html:ul is="agenda-list" id="agenda"></html:ul>
+        <html:ul is="agenda-list" id="agenda" role="listbox"></html:ul>
         <template id="agenda-listitem" xmlns="http://www.w3.org/1999/xhtml">
           <div class="agenda-date-header"></div>
           <div class="agenda-listitem-details">
             <div class="agenda-listitem-calendar"></div>
             <div class="agenda-listitem-details-inner">
               <time class="agenda-listitem-time"></time>
               <span class="agenda-listitem-title"></span>
               <span class="agenda-listitem-relative"></span>
--- a/mail/base/content/about3Pane.xhtml
+++ b/mail/base/content/about3Pane.xhtml
@@ -35,17 +35,17 @@
   <script defer="defer" src="chrome://messenger/content/pane-splitter.js"></script>
   <script defer="defer" src="chrome://messenger/content/tree-listbox.js"></script>
   <script defer="defer" src="chrome://messenger/content/jsTreeView.js"></script>
   <script defer="defer" src="chrome://messenger/content/mailContext.js"></script>
   <script defer="defer" src="chrome://messenger/content/about3Pane.js"></script>
 </head>
 <body class="layout-classic">
   <div id="folderTreePane" tabindex="-1">
-    <ul id="folderTree" is="tree-listbox"></ul>
+    <ul id="folderTree" is="tree-listbox" role="tree"></ul>
     <template id="folderTemplate">
       <li>
         <div class="container">
           <div class="twisty">
             <img class="twisty-icon" src="chrome://global/skin/icons/arrow-down-12.svg" alt="" />
           </div>
           <div class="icon"></div>
           <span class="name" tabindex="-1"></span>
--- a/mail/base/content/widgets/tree-listbox.js
+++ b/mail/base/content/widgets/tree-listbox.js
@@ -43,20 +43,31 @@
 
       connectedCallback() {
         if (this.hasConnected) {
           return;
         }
         this.hasConnected = true;
 
         this.setAttribute("is", "tree-listbox");
-        this.setAttribute("role", "listbox");
+        switch (this.getAttribute("role")) {
+          case "tree":
+            this.isTree = true;
+            break;
+          case "listbox":
+            this.isTree = false;
+            break;
+          default:
+            throw new RangeError(
+              `Unsupported role ${this.getAttribute("role")}`
+            );
+        }
         this.tabIndex = 0;
 
-        this._initRows(this);
+        this._initRows();
 
         if (this.querySelector("li")) {
           this.selectedIndex = 0;
         }
 
         this.addEventListener("click", this);
         this.addEventListener("keydown", this);
         this._mutationObserver.observe(this, {
@@ -87,16 +98,19 @@
         }
 
         if (
           row.classList.contains("children") &&
           event.target.closest(".twisty")
         ) {
           let rowIndex = this.rows.indexOf(row);
           let didCollapse = row.classList.toggle("collapsed");
+          if (this.isTree) {
+            row.setAttribute("aria-expanded", !didCollapse);
+          }
           if (didCollapse && row.querySelector(":is(ol, ul) > li.selected")) {
             // The selected row was hidden. Select the visible ancestor of it.
             this.selectedIndex = rowIndex;
           } else if (this.selectedIndex > rowIndex) {
             // Rows above the selected row have appeared or disappeared.
             // Update the index of the selected row, but don't fire a 'select'
             // event.
             this._selectedIndex = this.rows.indexOf(
@@ -212,29 +226,26 @@
           default:
             return;
         }
 
         event.preventDefault();
       }
 
       _mutationObserver = new MutationObserver(mutations => {
+        this._initRows();
         for (let mutation of mutations) {
           let ancestor = mutation.target.closest("li");
 
           for (let node of mutation.addedNodes) {
             if (node.nodeType != Node.ELEMENT_NODE || node.localName != "li") {
               continue;
             }
 
             node.classList.remove("selected");
-            this._initRows(node);
-            if (ancestor) {
-              ancestor.classList.add("children");
-            }
 
             if (this._selectedIndex == -1) {
               // There were no rows before this one was added. Select it.
               this.selectedIndex = 0;
             } else if (this._selectedIndex >= this.rows.indexOf(node)) {
               // The selected row is further down the list than the inserted
               // row. Update the selected index.
               this._selectedIndex += 1 + node.querySelectorAll("li").length;
@@ -284,66 +295,60 @@
                 // The selected row is further down the list than the removed
                 // row. Update the selected index.
                 if (node.localName == "li") {
                   this._selectedIndex--;
                 }
                 this._selectedIndex -= node.querySelectorAll("li").length;
               }
             }
-
-            if (
-              ancestor &&
-              (node.localName == "ul" ||
-                (node.localName == "li" &&
-                  !mutation.target.querySelector("li")))
-            ) {
-              // There's no rows left under `ancestor`.
-              ancestor.classList.remove("children");
-              ancestor.classList.remove("collapsed");
-            }
           }
         }
       });
 
       /**
-       * Adds the 'option' role and 'children' class to `ancestor` if
-       * appropriate and any descendants that are list items.
+       * Set the role attribute and classes for all descendants of the widget.
        */
-      _initRows(ancestor) {
-        let descendants = ancestor.querySelectorAll("li");
+      _initRows() {
+        let descendantItems = this.querySelectorAll("li");
+        let descendantLists = this.querySelectorAll("ol, ul");
 
-        if (ancestor.localName == "li") {
-          ancestor.setAttribute("role", "option");
-          if (descendants.length > 0) {
-            ancestor.classList.add("children");
+        for (let i = 0; i < descendantItems.length; i++) {
+          let row = descendantItems[i];
+          row.setAttribute("role", this.isTree ? "treeitem" : "option");
+          if (
+            i + 1 < descendantItems.length &&
+            row.contains(descendantItems[i + 1])
+          ) {
+            row.classList.add("children");
+            if (this.isTree) {
+              row.setAttribute(
+                "aria-expanded",
+                !row.classList.contains("collapsed")
+              );
+            }
+          } else {
+            row.classList.remove("children");
+            row.classList.remove("collapsed");
+            row.removeAttribute("aria-expanded");
           }
         }
 
-        for (let i = 0; i < descendants.length - 1; i++) {
-          let row = descendants[i];
-          row.setAttribute("role", "option");
-          row.classList.remove("selected");
-          if (i + 1 < descendants.length && row.contains(descendants[i + 1])) {
-            row.classList.add("children");
+        if (this.isTree) {
+          for (let list of descendantLists) {
+            list.setAttribute("role", "group");
           }
         }
 
         // Don't add any inline style if we don't need to animate.
         if (reducedMotionMedia.matches) {
           return;
         }
 
-        // Add the height attribute to the inline style of a child list in order
-        // to override the CSS declaration and guarantee a smooth transition
-        // unaffected by the addition or removal of the `.collapsed` class.
-        if (ancestor.matches("li.collapsed > :is(ol, ul)")) {
-          ancestor.style.height = "0";
-        }
-        for (let childList of ancestor.querySelectorAll(
+        for (let childList of this.querySelectorAll(
           "li.collapsed > :is(ol, ul)"
         )) {
           childList.style.height = "0";
         }
       }
 
       /**
        * Every visible row. Rows with collapsed ancestors are not included.
@@ -456,16 +461,19 @@
           this.selectedIndex = index;
         }
 
         if (
           row.classList.contains("children") &&
           !row.classList.contains("collapsed")
         ) {
           row.classList.add("collapsed");
+          if (this.isTree) {
+            row.setAttribute("aria-expanded", "false");
+          }
           row.dispatchEvent(new CustomEvent("collapsed", { bubbles: true }));
           this._animateCollapseRow(row);
         }
       }
 
       /**
        * Expands the row at `index` if it can be expanded.
        *
@@ -473,32 +481,35 @@
        */
       expandRowAtIndex(index) {
         let row = this.getRowAtIndex(index);
         if (
           row.classList.contains("children") &&
           row.classList.contains("collapsed")
         ) {
           row.classList.remove("collapsed");
+          if (this.isTree) {
+            row.setAttribute("aria-expanded", "true");
+          }
           row.dispatchEvent(new CustomEvent("expanded", { bubbles: true }));
           this._animateExpandRow(row);
         }
       }
 
       /**
        * Animate the collapsing of a row containing child items.
        *
        * @param {HTMLLIElement} row - The parent row element.
        */
       _animateCollapseRow(row) {
         if (reducedMotionMedia.matches) {
           return;
         }
 
-        let childList = row.querySelector(":is(ol, ul)");
+        let childList = row.querySelector("ol, ul");
         let childListHeight = childList.scrollHeight;
 
         let animation = childList.animate(
           [{ height: `${childListHeight}px` }, { height: "0" }],
           {
             duration: ANIMATION_DURATION_MS,
             easing: ANIMATION_EASING,
             fill: "both",
@@ -515,17 +526,17 @@
        *
        * @param {HTMLLIElement} row - The parent row element.
        */
       _animateExpandRow(row) {
         if (reducedMotionMedia.matches) {
           return;
         }
 
-        let childList = row.querySelector(":is(ol, ul)");
+        let childList = row.querySelector("ol, ul");
         let childListHeight = childList.scrollHeight;
 
         let animation = childList.animate(
           [{ height: "0" }, { height: `${childListHeight}px` }],
           {
             duration: ANIMATION_DURATION_MS,
             easing: ANIMATION_EASING,
             fill: "both",
--- a/mail/base/test/browser/files/orderableTreeListbox.xhtml
+++ b/mail/base/test/browser/files/orderableTreeListbox.xhtml
@@ -69,17 +69,17 @@
     }
   </style>
   <!-- This script is used for the automated test. -->
   <script defer="defer" src="chrome://messenger/content/tree-listbox.js"></script>
   <!-- This script is used when this file is loaded in a browser. -->
   <script defer="defer" src="../../../content/widgets/tree-listbox.js"></script>
 </head>
 <body>
-  <ol id="list" is="orderable-tree-listbox">
+  <ol id="list" is="orderable-tree-listbox" role="tree">
     <li id="row-1">
       <div draggable="true">
         <div class="twisty"></div>
         Item 1
       </div>
     </li>
     <li id="row-2">
       <div draggable="true">
--- a/mail/base/test/browser/files/treeListbox.xhtml
+++ b/mail/base/test/browser/files/treeListbox.xhtml
@@ -47,17 +47,17 @@
     }
     li.children.collapsed > div > div.twisty {
       background-color: red;
     }
   </style>
   <script defer="defer" src="chrome://messenger/content/tree-listbox.js"></script>
 </head>
 <body>
-  <ul is="tree-listbox">
+  <ul is="tree-listbox" role="tree">
     <li id="row-1">
       <div>
         <div class="twisty"></div>
         Item with no children
       </div>
     </li>
     <li id="row-2">
       <div>
--- a/mail/components/addrbook/content/aboutAddressBook.xhtml
+++ b/mail/components/addrbook/content/aboutAddressBook.xhtml
@@ -65,41 +65,41 @@
                          class="toolbarbutton-1"
                          data-l10n-id="about-addressbook-toolbar-new-list"/>
       <xul:toolbarbutton id="toolbarImport"
                          class="toolbarbutton-1"
                          data-l10n-id="about-addressbook-toolbar-import"/>
     </xul:toolbar>
   </xul:toolbox>
   <div id="booksPane">
-    <ul is="ab-tree-listbox" id="books">
-      <li role="option" id="allAddressBooks" class="bookRow noDelete readOnly">
+    <ul is="ab-tree-listbox" id="books" role="tree">
+      <li id="allAddressBooks" class="bookRow noDelete readOnly">
         <div class="bookRow-container">
           <div class="twisty"></div>
           <div class="bookRow-icon"></div>
           <span class="bookRow-name" tabindex="-1" data-l10n-id="all-address-books"></span>
           <div class="bookRow-menu"></div>
         </div>
       </li>
     </ul>
     <template id="bookRow">
-      <li role="option" class="bookRow">
+      <li class="bookRow">
         <div class="bookRow-container">
           <div class="twisty">
             <img class="twisty-icon" src="chrome://messenger/skin/icons/new/nav-down-sm.svg" alt="" />
           </div>
           <div class="bookRow-icon"></div>
           <span class="bookRow-name" tabindex="-1"></span>
           <div class="bookRow-menu"></div>
         </div>
         <ul></ul>
       </li>
     </template>
     <template id="listRow">
-      <li class="listRow" role="option">
+      <li class="listRow">
         <div class="listRow-container">
           <div class="listRow-icon"></div>
           <span class="listRow-name" tabindex="-1"></span>
           <div class="listRow-menu"></div>
         </div>
       </li>
     </template>
   </div>
--- a/mailnews/base/prefs/content/AccountManager.xhtml
+++ b/mailnews/base/prefs/content/AccountManager.xhtml
@@ -46,21 +46,22 @@
     window.addEventListener("unload", event => { onUnload(); });
   </script>
 </head>
 <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
   <stringbundle id="bundle_brand" src="chrome://branding/locale/brand.properties"/>
   <stringbundle id="bundle_prefs" src="chrome://messenger/locale/prefs.properties"/>
   <html:aside id="accountTreeBox">
     <html:ol is="orderable-tree-listbox" id="accounttree"
+             role="tree"
              flex="1"
              onselect="onAccountTreeSelect(null, null);">
     </html:ol>
     <template id="accountTreeItem" xmlns="http://www.w3.org/1999/xhtml">
-      <li role="option">
+      <li>
         <div draggable="true">
           <div class="twisty">
             <img class="twisty-icon" alt=""
                  src="chrome://global/skin/icons/arrow-down-12.svg" />
           </div>
           <div class="icon"></div>
           <span class="name" tabindex="-1"></span>
         </div>