Bug 1567600 - Part 3: Better accessibility for named-deck and about:addons detail view r=Gijs
authorMark Striemer <mstriemer@mozilla.com>
Thu, 22 Aug 2019 21:00:09 +0000
changeset 553265 1d8cca181d354840fd5e88f2245db267bb6c6f1d
parent 553264 fb9da1d831a3e6a13cff42d20cdb31defb01a4f9
child 553266 06aca0857b322e1957fa4bdbf9fdc3efae2fd01c
push id2165
push userffxbld-merge
push dateMon, 14 Oct 2019 16:30:58 +0000
treeherdermozilla-release@0eae18af659f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersGijs
bugs1567600
milestone70.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1567600 - Part 3: Better accessibility for named-deck and about:addons detail view r=Gijs Differential Revision: https://phabricator.services.mozilla.com/D42370
toolkit/mozapps/extensions/content/aboutaddons.css
toolkit/mozapps/extensions/content/aboutaddons.html
toolkit/mozapps/extensions/content/aboutaddons.js
toolkit/mozapps/extensions/content/extensions.js
toolkit/mozapps/extensions/content/named-deck.js
toolkit/mozapps/extensions/test/browser/browser_html_detail_view.js
toolkit/mozapps/extensions/test/browser/browser_html_updates.js
--- a/toolkit/mozapps/extensions/content/aboutaddons.css
+++ b/toolkit/mozapps/extensions/content/aboutaddons.css
@@ -431,16 +431,12 @@ panel-item-separator {
   margin-block: 4px;
 }
 
 addon-permissions-list > .addon-detail-row:first-of-type {
   border-top: none;
 }
 
 .deck-tab-group {
-  border-bottom: 1px solid var(--in-content-box-border-color);
-  border-top: 1px solid var(--in-content-box-border-color);
   margin-top: 8px;
   /* Pull the buttons flush with the side of the card */
   margin-inline: calc(var(--card-padding) * -1);
-  font-size: 0;
-  line-height: 0;
 }
--- a/toolkit/mozapps/extensions/content/aboutaddons.html
+++ b/toolkit/mozapps/extensions/content/aboutaddons.html
@@ -113,20 +113,22 @@
       <div class="disco-description-statistics">
         <five-star-rating></five-star-rating>
         <span class="disco-user-count"></span>
       </div>
     </template>
 
     <template name="addon-details">
       <div class="deck-tab-group">
-        <named-deck-button deck="details-deck" name="details" data-l10n-id="details-addon-button"></named-deck-button>
-        <named-deck-button deck="details-deck" name="preferences" data-l10n-id="preferences-addon-button"></named-deck-button>
-        <named-deck-button deck="details-deck" name="permissions" data-l10n-id="permissions-addon-button"></named-deck-button>
-        <named-deck-button deck="details-deck" name="release-notes" data-l10n-id="release-notes-addon-button"></named-deck-button>
+        <named-deck-button-group>
+          <named-deck-button deck="details-deck" name="details" data-l10n-id="details-addon-button"></named-deck-button>
+          <named-deck-button deck="details-deck" name="preferences" data-l10n-id="preferences-addon-button"></named-deck-button>
+          <named-deck-button deck="details-deck" name="permissions" data-l10n-id="permissions-addon-button"></named-deck-button>
+          <named-deck-button deck="details-deck" name="release-notes" data-l10n-id="release-notes-addon-button"></named-deck-button>
+        </named-deck-button-group>
       </div>
       <named-deck id="details-deck">
         <section name="details">
           <div class="addon-detail-description"></div>
           <div class="addon-detail-contribute">
             <label data-l10n-id="detail-contributions-description"></label>
             <button
               class="addon-detail-contribute-button"
--- a/toolkit/mozapps/extensions/content/aboutaddons.js
+++ b/toolkit/mozapps/extensions/content/aboutaddons.js
@@ -11,16 +11,17 @@
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   AddonManager: "resource://gre/modules/AddonManager.jsm",
   AddonRepository: "resource://gre/modules/addons/AddonRepository.jsm",
   AMTelemetry: "resource://gre/modules/AddonManager.jsm",
   ClientID: "resource://gre/modules/ClientID.jsm",
   DeferredTask: "resource://gre/modules/DeferredTask.jsm",
   E10SUtils: "resource://gre/modules/E10SUtils.jsm",
+  ExtensionCommon: "resource://gre/modules/ExtensionCommon.jsm",
   ExtensionParent: "resource://gre/modules/ExtensionParent.jsm",
   ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.jsm",
   PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
 });
 
 XPCOMUtils.defineLazyGetter(this, "browserBundle", () => {
   return Services.strings.createBundle(
     "chrome://browser/locale/browser.properties"
@@ -1454,17 +1455,18 @@ class AddonDetails extends HTMLElement {
       }
     } else {
       isAddonOptionsUIAllowed(addon).then(allowed => {
         prefsBtn.hidden = !allowed;
       });
     }
 
     // Hide the tab group if "details" is the only visible button.
-    this.tabGroup.hidden = Array.from(this.tabGroup.children).every(button => {
+    let tabGroupButtons = this.tabGroup.querySelectorAll("named-deck-button");
+    this.tabGroup.hidden = Array.from(tabGroupButtons).every(button => {
       return button.name == "details" || button.hidden;
     });
 
     // Show the update check button if necessary. The button might not exist if
     // the add-on doesn't support updates.
     let updateButton = this.querySelector('[action="update-check"]');
     if (updateButton) {
       updateButton.hidden =
@@ -1756,17 +1758,17 @@ class AddonCard extends HTMLElement {
           });
           break;
         case "preferences":
           if (getOptionsType(addon) == "tab") {
             this.recordActionEvent("preferences", "external");
             openOptionsInTab(addon.optionsURL);
           } else if (getOptionsType(addon) == "inline") {
             this.recordActionEvent("preferences", "inline");
-            loadViewFn("detail", this.addon.id, "preferences");
+            loadViewFn(`detail/${this.addon.id}/preferences`, e);
           }
           break;
         case "remove":
           {
             this.panel.hide();
             let {
               remove,
               report,
@@ -1783,17 +1785,17 @@ class AddonCard extends HTMLElement {
                 });
               }
             } else {
               this.sendEvent("remove-cancelled");
             }
           }
           break;
         case "expand":
-          loadViewFn("detail", this.addon.id);
+          loadViewFn(`detail/${this.addon.id}`, e);
           break;
         case "more-options":
           // Open panel on click from the keyboard.
           if (e.mozInputSource == MouseEvent.MOZ_SOURCE_KEYBOARD) {
             this.panel.toggle(e);
           }
           break;
         case "report":
@@ -1809,17 +1811,17 @@ class AddonCard extends HTMLElement {
           }
           break;
         default:
           // Handle a click on the card itself.
           if (
             !this.expanded &&
             (e.target === this.addonNameEl || !e.target.closest("a"))
           ) {
-            loadViewFn("detail", this.addon.id);
+            loadViewFn(`detail/${this.addon.id}`, e);
           } else if (
             e.target.localName == "a" &&
             e.target.getAttribute("data-telemetry-name")
           ) {
             let value = e.target.getAttribute("data-telemetry-name");
             AMTelemetry.recordLinkEvent({
               object: "aboutAddons",
               addon,
@@ -2070,21 +2072,25 @@ class AddonCard extends HTMLElement {
   render() {
     this.textContent = "";
 
     let { addon } = this;
     if (!addon) {
       throw new Error("addon-card must be initialized with setAddon()");
     }
 
+    let headingId = ExtensionCommon.makeWidgetId(`${addon.name}-heading`);
+    this.setAttribute("aria-labelledby", headingId);
+    this.setAttribute("addon-id", addon.id);
+
     this.card = importTemplate("card").firstElementChild;
-    this.setAttribute("addon-id", addon.id);
 
     let nameContainer = this.card.querySelector(".addon-name-container");
-    let nameHeading = document.createElement("h3");
+    let headingLevel = this.expanded ? "h1" : "h3";
+    let nameHeading = document.createElement(headingLevel);
     nameHeading.classList.add("addon-name");
     if (!this.expanded) {
       let name = document.createElement("a");
       name.classList.add("addon-name-link");
       name.href = `addons://detail/${addon.id}`;
       nameHeading.appendChild(name);
       this.addonNameEl = name;
     } else {
@@ -2111,16 +2117,20 @@ class AddonCard extends HTMLElement {
 
       // If we're re-rendering we still need to append the details since the
       // entire card was emptied at the beginning of the render.
       this.card.appendChild(this.details);
     }
 
     this.appendChild(this.card);
 
+    if (this.expanded && this.keyboardNavigation) {
+      requestAnimationFrame(() => this.optionsButton.focus());
+    }
+
     // Return the promise of details rendering to wait on in DetailView.
     return doneRenderPromise;
   }
 
   sendEvent(name, detail) {
     this.dispatchEvent(new CustomEvent(name, { detail }));
   }
 
@@ -2285,17 +2295,17 @@ class RecommendedAddonCard extends HTMLE
         break;
       case "manage-addon":
         AMTelemetry.recordActionEvent({
           object: "aboutAddons",
           view: getTelemetryViewName(this),
           action: "manage",
           addon: this.discoAddon,
         });
-        loadViewFn("detail", this.addonId);
+        loadViewFn(`detail/${this.addonId}`, event);
         break;
       default:
         if (event.target.matches(".disco-addon-author a[href]")) {
           AMTelemetry.recordLinkEvent({
             object: "aboutAddons",
             // Note: This is not "author" nor "homepage", because the link text
             // is the author name, but the link URL the add-on's listing URL.
             value: "discohome",
@@ -3046,41 +3056,43 @@ class ListView {
     await list.render();
 
     this.root.textContent = "";
     this.root.appendChild(frag);
   }
 }
 
 class DetailView {
-  constructor({ param, root }) {
+  constructor({ isKeyboardNavigation, param, root }) {
     let [id, selectedTab] = param.split("/");
     this.id = id;
     this.selectedTab = selectedTab;
     this.root = root;
+    this.isKeyboardNavigation = isKeyboardNavigation;
   }
 
   async render() {
     let addon = await AddonManager.getAddonByID(this.id);
 
     if (!addon) {
       replaceWithDefaultViewFn();
       return;
     }
 
     let card = document.createElement("addon-card");
 
     // Ensure the category for this add-on type is selected.
     setCategoryFn(addon.type);
 
     // Go back to the list view when the add-on is removed.
-    card.addEventListener("remove", () => loadViewFn("list", addon.type));
+    card.addEventListener("remove", () => loadViewFn(`list/${addon.type}`));
 
     card.setAddon(addon);
     card.expand();
+    card.keyboardNavigation = this.isKeyboardNavigation;
     await card.render();
     if (
       this.selectedTab === "preferences" &&
       (await isAddonOptionsUIAllowed(addon))
     ) {
       card.showPrefs();
     }
 
@@ -3176,23 +3188,27 @@ function initialize(opts) {
   );
 }
 
 /**
  * Called from extensions.js to load a view. The view's render method should
  * resolve once the view has been updated to conform with other about:addons
  * views.
  */
-async function show(type, param) {
+async function show(type, param, { isKeyboardNavigation }) {
   let container = document.createElement("div");
   container.setAttribute("current-view", type);
   if (type == "list") {
     await new ListView({ param, root: container }).render();
   } else if (type == "detail") {
-    await new DetailView({ param, root: container }).render();
+    await new DetailView({
+      isKeyboardNavigation,
+      param,
+      root: container,
+    }).render();
   } else if (type == "discover") {
     let discoverView = new DiscoveryView();
     let elem = discoverView.render();
     await document.l10n.translateFragment(elem);
     container.append(elem);
   } else if (type == "updates") {
     await new UpdatesView({ param, root: container }).render();
   } else {
--- a/toolkit/mozapps/extensions/content/extensions.js
+++ b/toolkit/mozapps/extensions/content/extensions.js
@@ -741,34 +741,38 @@ var gViewController = {
   },
 
   get isLoading() {
     return (
       !this.currentViewObj || this.currentViewObj.node.hasAttribute("loading")
     );
   },
 
-  loadView(aViewId) {
+  loadView(aViewId, sourceEvent) {
     var isRefresh = false;
     if (aViewId == this.currentViewId) {
       if (this.isLoading) {
         return;
       }
       if (!("refresh" in this.currentViewObj)) {
         return;
       }
       if (!this.currentViewObj.canRefresh()) {
         return;
       }
       isRefresh = true;
     }
 
+    let isKeyboardNavigation =
+      sourceEvent &&
+      sourceEvent.mozInputSource === MouseEvent.MOZ_SOURCE_KEYBOARD;
     var state = {
       view: aViewId,
       previousView: this.currentViewId,
+      isKeyboardNavigation,
     };
     if (!isRefresh) {
       gHistory.pushState(state);
       this.lastHistoryIndex = gHistory.index;
     }
     this.loadViewInternal(aViewId, this.currentViewId, state);
   },
 
@@ -810,17 +814,17 @@ var gViewController = {
     if (node.parentNode == this.headeredViewsDeck) {
       this.headeredViewsDeck.selectedPanel = node;
       this.viewPort.selectedPanel = this.headeredViews;
     } else {
       this.viewPort.selectedPanel = node;
     }
   },
 
-  loadViewInternal(aViewId, aPreviousView, aState) {
+  loadViewInternal(aViewId, aPreviousView, aState, aEvent) {
     var view = this.parseViewId(aViewId);
 
     if (!view.type || !(view.type in this.viewObjects)) {
       throw Components.Exception("Invalid view: " + view.type);
     }
 
     var viewObj = this.viewObjects[view.type];
     if (!viewObj.node) {
@@ -2181,27 +2185,19 @@ var gDragDrop = {
 const addonTypes = new Set([
   "extension",
   "theme",
   "plugin",
   "dictionary",
   "locale",
 ]);
 const htmlViewOpts = {
-  loadViewFn(type, ...params) {
-    let viewId = `addons://${type}`;
-    if (params.length > 0) {
-      for (let param of params) {
-        viewId += "/" + encodeURIComponent(param);
-      }
-    } else {
-      viewId += "/";
-    }
-
-    gViewController.loadView(viewId);
+  loadViewFn(view, sourceEvent) {
+    let viewId = `addons://${view}`;
+    gViewController.loadView(viewId, sourceEvent);
   },
   replaceWithDefaultViewFn() {
     gViewController.replaceView(gViewDefault);
   },
   setCategoryFn(name) {
     if (addonTypes.has(name)) {
       gCategories.select(`addons://list/${name}`);
     }
@@ -2234,21 +2230,21 @@ function htmlView(type) {
     node: null,
     isRoot: type != "detail",
 
     initialize() {
       this._browser = getHtmlBrowser();
       this.node = this._browser.closest("#html-view");
     },
 
-    async show(param, request, state, refresh) {
+    async show(param, request, state) {
       await htmlBrowserLoaded;
       this.node.setAttribute("type", type);
       this.node.setAttribute("param", param);
-      await this._browser.contentWindow.show(type, param);
+      await this._browser.contentWindow.show(type, param, state);
       gViewController.updateCommands();
       gViewController.notifyViewChanged();
     },
 
     async hide() {
       await htmlBrowserLoaded;
       this.node.removeAttribute("type");
       this.node.removeAttribute("param");
--- a/toolkit/mozapps/extensions/content/named-deck.js
+++ b/toolkit/mozapps/extensions/content/named-deck.js
@@ -58,32 +58,38 @@ class NamedDeckButton extends HTMLElemen
 
       :host([selected]) button {
         border-top-color: var(--in-content-border-highlight);
         color: var(--in-content-category-text-selected);
       }
     `;
     this.shadowRoot.appendChild(style);
 
-    let button = document.createElement("button");
-    button.appendChild(document.createElement("slot"));
-    this.shadowRoot.appendChild(button);
+    this.button = document.createElement("button");
+    this.button.setAttribute("role", "tab");
+    this.button.appendChild(document.createElement("slot"));
+    this.shadowRoot.appendChild(this.button);
 
     this.addEventListener("click", this);
   }
 
   connectedCallback() {
+    this.id = `${this.deckId}-button-${this.name}`;
     this.setSelectedFromDeck();
     document.addEventListener("view-changed", this, { capture: true });
   }
 
   disconnectedCallback() {
     document.removeEventListener("view-changed", this, { capture: true });
   }
 
+  focus() {
+    this.button.focus();
+  }
+
   get deckId() {
     return this.getAttribute("deck");
   }
 
   set deckId(val) {
     this.setAttribute("deck", val);
   }
 
@@ -107,25 +113,90 @@ class NamedDeckButton extends HTMLElemen
   }
 
   get selected() {
     return this.hasAttribute("selected");
   }
 
   set selected(val) {
     this.toggleAttribute("selected", !!val);
+    this.button.setAttribute("aria-selected", !!val);
+    this.button.setAttribute("tabindex", val ? "0" : "-1");
   }
 
   setSelectedFromDeck() {
     let { deck } = this;
     this.selected = deck && deck.selectedViewName == this.name;
   }
 }
 customElements.define("named-deck-button", NamedDeckButton);
 
+class NamedDeckButtonGroup extends HTMLElement {
+  constructor() {
+    super();
+    this.attachShadow({ mode: "open" });
+
+    let style = document.createElement("style");
+    style.textContent = `
+      div {
+        border-bottom: 1px solid var(--in-content-box-border-color);
+        border-top: 1px solid var(--in-content-box-border-color);
+        font-size: 0;
+        line-height: 0;
+      }
+    `;
+    this.shadowRoot.appendChild(style);
+
+    let container = document.createElement("div");
+    container.setAttribute("role", "tablist");
+    container.appendChild(document.createElement("slot"));
+    this.shadowRoot.appendChild(container);
+
+    this.addEventListener("keydown", this);
+  }
+
+  handleEvent(e) {
+    if (
+      e.type === "keydown" &&
+      e.target.localName === "named-deck-button" &&
+      ["ArrowLeft", "ArrowRight"].includes(e.key)
+    ) {
+      let previousDirectionKey =
+        document.dir === "rtl" ? "ArrowRight" : "ArrowLeft";
+      this.walker.currentNode = e.target;
+      let nextItem =
+        e.key === previousDirectionKey
+          ? this.walker.previousNode()
+          : this.walker.nextNode();
+      if (nextItem) {
+        nextItem.focus();
+      }
+    }
+  }
+
+  get walker() {
+    if (!this._walker) {
+      this._walker = document.createTreeWalker(this, NodeFilter.SHOW_ELEMENT, {
+        acceptNode: node => {
+          if (
+            node.hidden ||
+            node.disabled ||
+            node.localName !== "named-deck-button"
+          ) {
+            return NodeFilter.FILTER_REJECT;
+          }
+          return NodeFilter.FILTER_ACCEPT;
+        },
+      });
+    }
+    return this._walker;
+  }
+}
+customElements.define("named-deck-button-group", NamedDeckButtonGroup);
+
 /**
  * A deck that is indexed by the "name" attribute of its children. The
  * <named-deck-button> element is a companion element that can update its state
  * and change the view of a <named-deck>.
  *
  * When the deck is connected it will set the first child as the selected view
  * if a view is not already selected.
  *
@@ -205,17 +276,21 @@ class NamedDeck extends HTMLElement {
 
   /**
    * Set the slot attribute on all of the views to ensure only the selected view
    * is shown.
    */
   _setSelectedViewAttributes() {
     let { selectedViewName } = this;
     for (let view of this.children) {
-      if (view.getAttribute("name") == selectedViewName) {
+      let name = view.getAttribute("name");
+      view.setAttribute("aria-labelledby", `${this.id}-button-${name}`);
+      view.setAttribute("role", "tabpanel");
+
+      if (name === selectedViewName) {
         view.slot = "selected";
       } else {
         view.slot = "";
       }
     }
   }
 }
 customElements.define("named-deck", NamedDeck);
--- a/toolkit/mozapps/extensions/test/browser/browser_html_detail_view.js
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_detail_view.js
@@ -82,28 +82,30 @@ function checkOptions(doc, options, expe
       { id: expected.label, args: null },
       "The label has the right text"
     );
   }
 }
 
 function assertDeckHeadingHidden(group) {
   ok(group.hidden, "The tab group is hidden");
-  for (let button of group.children) {
+  let buttons = group.querySelectorAll("named-deck-button");
+  for (let button of buttons) {
     ok(button.offsetHeight == 0, `The ${button.name} is hidden`);
   }
 }
 
 function assertDeckHeadingButtons(group, visibleButtons) {
   ok(!group.hidden, "The tab group is shown");
+  let buttons = group.querySelectorAll("named-deck-button");
   ok(
-    group.children.length >= visibleButtons.length,
+    buttons.length >= visibleButtons.length,
     `There should be at least ${visibleButtons.length} buttons`
   );
-  for (let button of group.children) {
+  for (let button of buttons) {
     if (visibleButtons.includes(button.name)) {
       ok(!button.hidden, `The ${button.name} is shown`);
     } else {
       ok(button.hidden, `The ${button.name} is hidden`);
     }
   }
 }
 
--- a/toolkit/mozapps/extensions/test/browser/browser_html_updates.js
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_updates.js
@@ -394,17 +394,17 @@ add_task(async function testReleaseNotes
   a.click();
   let tab = await tabOpened;
   BrowserTestUtils.removeTab(tab);
 
   let originalContent = notes.innerHTML;
 
   info("Switch away and back to release notes");
   // Load details view.
-  let detailsBtn = tabGroup.firstElementChild;
+  let detailsBtn = tabGroup.querySelector('named-deck-button[name="details"]');
   let viewChanged = BrowserTestUtils.waitForEvent(deck, "view-changed");
   detailsBtn.click();
   await viewChanged;
 
   // Load release notes again, verify they weren't loaded.
   viewChanged = BrowserTestUtils.waitForEvent(deck, "view-changed");
   let notesCached = BrowserTestUtils.waitForEvent(
     notes,
@@ -501,17 +501,17 @@ add_task(async function testReleaseNotes
   is(
     doc.l10n.getAttributes(notes.firstElementChild).id,
     "release-notes-error",
     "The error message is shown"
   );
 
   info("Switch away and back to release notes");
   // Load details view.
-  let detailsBtn = tabGroup.firstElementChild;
+  let detailsBtn = tabGroup.querySelector('named-deck-button[name="details"]');
   let viewChanged = BrowserTestUtils.waitForEvent(deck, "view-changed");
   detailsBtn.click();
   await viewChanged;
 
   // Load release notes again, verify they weren't loaded.
   viewChanged = BrowserTestUtils.waitForEvent(deck, "view-changed");
   let notesCached = BrowserTestUtils.waitForEvent(
     notes,