Repository: GoogleChrome/lighthouse
Pull request #9433 (2019-07-22 23:30:01)

report(accessibility): make dropdown match ARIA action menu button pattern
**Summary**
This improves the accessibility of the report dropdown by adding ARIA attributes and interactive behavior in-line with the [WCAG menu button pattern][1], specifically the [navigation menu button][2].

***Screenshot of NVDA speech view + the dropdown***
![evenmore-improved-dropdown-NVDA](https://user-images.githubusercontent.com/309310/62176905-ec249f00-b2f7-11e9-9d28-f9341170a396.png)

***Screenshot of button accessibility tree***
![dd-button-accessibility-tree-open](https://user-images.githubusercontent.com/309310/62176859-c7302c00-b2f7-11e9-8a3a-297ed5c6671e.PNG)

***Screenshot of menu item accessibility tree***
![dd-menuitem-accessibility-tree](https://user-images.githubusercontent.com/309310/62176851-c0091e00-b2f7-11e9-9d15-c2bc519ec8b2.PNG)

***Gif of keyboard interactions***
![audits-dropdown](https://user-images.githubusercontent.com/309310/62176845-baabd380-b2f7-11e9-83d5-445c0a449383.gif)

This will meet WCAG success criteria [1.3.1 Info and Relationship][3] and [4.1.2 Name, Role, Value][4]

**Related Issues/PRs**
https://github.com/GoogleChrome/lighthouse/issues/9183

[1]: https://www.w3.org/TR/2019/NOTE-wai-aria-practices-1.1-20190207/#menubutton
[2]: https://www.w3.org/TR/2019/NOTE-wai-aria-practices-1.1-20190207/examples/menu-button/menu-button-links.html
[3]: https://www.w3.org/WAI/WCAG21/quickref/#info-and-relationships
[4]: https://www.w3.org/WAI/WCAG21/quickref/#name-role-value

<!-- Provide any additional information we might need to understand the pull request -->

johnemau: #### Quick update
I am working on feedback and will have changes ready for another round of review early next week.
johnemau: > IDK about the accessibility stuff, but I commented on what I do know about :)

I have implemented the requested feedback, PTAL @connorjclark
johnemau: *ping* @connorjclark, @patrickhulce 
johnemau: PTAL @paulirish 
johnemau: > this impl looks fine in general.

Thanks for the review!

> after we land this tomorrow would you be willing to do a small followup which refactors the toolsButton/toolsDropdown into a separate class?

You got it! I have [the refactor started in a branch][1] and will open the PR once this is merged.

[1]: https://github.com/johnemau/lighthouse/compare/drop...johnemau:drop-refactor

Patch:
diff --git a/lighthouse-core/report/html/renderer/report-ui-features.js b/lighthouse-core/report/html/renderer/report-ui-features.js
index 41be88539..94a07a617 100644
--- a/lighthouse-core/report/html/renderer/report-ui-features.js
+++ b/lighthouse-core/report/html/renderer/report-ui-features.js
@@ -51,6 +51,8 @@ class ReportUIFeatures {
     /** @type {HTMLElement} */
     this.toolsButton; // eslint-disable-line no-unused-expressions
     /** @type {HTMLElement} */
+    this.toolsDropDown; // eslint-disable-line no-unused-expressions
+    /** @type {HTMLElement} */
     this.topbarEl; // eslint-disable-line no-unused-expressions
     /** @type {HTMLElement} */
     this.scoreScaleEl; // eslint-disable-line no-unused-expressions
@@ -62,6 +64,8 @@ class ReportUIFeatures {
     this.onMediaQueryChange = this.onMediaQueryChange.bind(this);
     this.onCopy = this.onCopy.bind(this);
     this.onToolsButtonClick = this.onToolsButtonClick.bind(this);
+    this.onToolsButtonKeydown = this.onToolsButtonKeydown.bind(this);
+    this.onToolsDropDownKeydown = this.onToolsDropDownKeydown.bind(this);
     this.onToolAction = this.onToolAction.bind(this);
     this.onKeyDown = this.onKeyDown.bind(this);
     this.onKeyUp = this.onKeyUp.bind(this);
@@ -70,6 +74,9 @@ class ReportUIFeatures {
     this.expandAllDetails = this.expandAllDetails.bind(this);
     this._toggleDarkTheme = this._toggleDarkTheme.bind(this);
     this._updateStickyHeaderOnScroll = this._updateStickyHeaderOnScroll.bind(this);
+    this._getNextDropDownItem = this._getNextDropDownItem.bind(this);
+    this._getNextSelectableNode = this._getNextSelectableNode.bind(this);
+    this._getPreviousDropDownItem = this._getPreviousDropDownItem.bind(this);
   }
 
   /**
@@ -208,9 +215,11 @@ class ReportUIFeatures {
   _setupToolsButton() {
     this.toolsButton = this._dom.find('.lh-tools__button', this._document);
     this.toolsButton.addEventListener('click', this.onToolsButtonClick);
+    this.toolsButton.addEventListener('keydown', this.onToolsButtonKeydown);
 
-    const dropdown = this._dom.find('.lh-tools__dropdown', this._document);
-    dropdown.addEventListener('click', this.onToolAction);
+    this.toolsDropDown = this._dom.find('.lh-tools__dropdown', this._document);
+    this.toolsDropDown.addEventListener('click', this.onToolAction);
+    this.toolsDropDown.addEventListener('keydown', this.onToolsDropDownKeydown);
   }
 
   _setupThirdPartyFilter() {
@@ -383,6 +392,31 @@ class ReportUIFeatures {
 
   closeToolsDropdown() {
     this.toolsButton.classList.remove('active');
+    this.toolsButton.setAttribute('aria-expanded', 'false');
+    this._document.removeEventListener('keydown', this.onKeyDown);
+    if (this.toolsDropDown.contains(this._document.activeElement)) {
+      // Refocus on the tools button if the drop down last had focus
+      this.toolsButton.focus();
+    }
+  }
+
+  /**
+   * @param {HTMLElement} firstFocusElement
+   */
+  openToolsDropDown(firstFocusElement) {
+    if (this.toolsButton.classList.contains('active')) {
+      // If the drop down is already open focus on the element
+      firstFocusElement.focus();
+    } else {
+      // Wait for drop down transition to complete so options are focusable.
+      this.toolsDropDown.addEventListener('transitionend', () => {
+        firstFocusElement.focus();
+      }, {once: true});
+    }
+
+    this.toolsButton.classList.add('active');
+    this.toolsButton.setAttribute('aria-expanded', 'true');
+    this._document.addEventListener('keydown', this.onKeyDown);
   }
 
   /**
@@ -391,8 +425,113 @@ class ReportUIFeatures {
    */
   onToolsButtonClick(e) {
     e.preventDefault();
-    this.toolsButton.classList.toggle('active');
-    this._document.addEventListener('keydown', this.onKeyDown);
+    e.stopImmediatePropagation();
+
+    if (this.toolsButton.classList.contains('active')) {
+      this.closeToolsDropdown();
+    } else {
+      this.openToolsDropDown(this._getNextDropDownItem());
+    }
+  }
+
+  /**
+   * Handler for tool button.
+   * @param {KeyboardEvent} e
+   */
+  onToolsButtonKeydown(e) {
+    switch (e.code) {
+      case 'ArrowUp':
+        e.preventDefault();
+        this.openToolsDropDown(this._getPreviousDropDownItem());
+        break;
+      case 'ArrowDown':
+      case 'Enter':
+      case ' ':
+        e.preventDefault();
+        this.openToolsDropDown(this._getNextDropDownItem());
+        break;
+      default:
+       // no op
+    }
+  }
+
+  /**
+   * Handler for tool DropDown.
+   * @param {KeyboardEvent} e
+   */
+  onToolsDropDownKeydown(e) {
+    const el = /** @type {?HTMLElement} */ (e.target);
+
+    switch (e.code) {
+      case 'ArrowUp':
+        e.preventDefault();
+        this._getPreviousDropDownItem(el).focus();
+        break;
+      case 'ArrowDown':
+        e.preventDefault();
+        this._getNextDropDownItem(el).focus();
+        break;
+      case 'Home':
+        e.preventDefault();
+        this._getNextDropDownItem().focus();
+        break;
+      case 'End':
+        e.preventDefault();
+        this._getPreviousDropDownItem().focus();
+        break;
+      default:
+       // no op
+    }
+  }
+
+  /**
+   * @param {Array<Node>} allNodes
+   * @param {?Node=} startNode
+   * @returns {Node}
+   */
+  _getNextSelectableNode(allNodes, startNode) {
+    const nodes = allNodes.filter((node) => {
+      if (!(node instanceof HTMLElement)) {
+        return false;
+      }
+
+      // 'Save as Gist' option may be disabled.
+      if (node.hasAttribute('disabled')) {
+        return false;
+      }
+
+      // 'Save as Gist' option may have display none.
+      if (window.getComputedStyle(node).display === 'none') {
+        return false;
+      }
+
+      return true;
+    });
+
+    let nextIndex = startNode ? (nodes.indexOf(startNode) + 1) : 0;
+    if (nextIndex >= nodes.length) {
+      nextIndex = 0;
+    }
+
+    return nodes[nextIndex];
+  }
+
+  /**
+   * @param {?Element=} startEl
+   * @returns {HTMLElement}
+   */
+  _getNextDropDownItem(startEl) {
+    const nodes = Array.from(this.toolsDropDown.childNodes);
+    return /** @type {HTMLElement} */ (this._getNextSelectableNode(nodes, startEl));
+  }
+
+  /**
+   * @param {?Element=} startEl
+   * @returns {HTMLElement}
+   */
+  _getPreviousDropDownItem(startEl) {
+    const nodes = Array.from(this.toolsDropDown.childNodes).reverse();
+    return /** @type {HTMLElement} */ (this._getNextSelectableNode(nodes, startEl));
   }
 
   /**
@@ -424,12 +563,10 @@ class ReportUIFeatures {
         break;
       case 'print-summary':
         this.collapseAllDetails();
-        this.closeToolsDropdown();
         this._print();
         break;
       case 'print-expanded':
         this.expandAllDetails();
-        this.closeToolsDropdown();
         this._print();
         break;
       case 'save-json': {
@@ -464,7 +601,6 @@ class ReportUIFeatures {
     }
 
     this.closeToolsDropdown();
-    this._document.removeEventListener('keydown', this.onKeyDown);
   }
 
   _print() {
diff --git a/lighthouse-core/report/html/templates.html b/lighthouse-core/report/html/templates.html
index fd08b45bd..3cd8cdefc 100644
--- a/lighthouse-core/report/html/templates.html
+++ b/lighthouse-core/report/html/templates.html
@@ -370,22 +370,22 @@ limitations under the License.
     <a href="" class="lh-topbar__url" target="_blank" rel="noopener"></a>
 
     <div class="lh-tools">
-      <button class="report-icon report-icon--share lh-tools__button" title="Tools menu" aria-label="Toggle report tools menu">
+      <button id="lh-tools-button" class="report-icon report-icon--share lh-tools__button" title="Tools menu" aria-label="Toggle report tools menu" aria-haspopup="menu" aria-expanded="false" aria-controls="lh-tools-dropdown">
         <svg width="100%" height="100%" viewBox="0 0 24 24">
             <path d="M0 0h24v24H0z" fill="none"/>
             <path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
         </svg>
       </button>
-      <div class="lh-tools__dropdown">
+      <div id="lh-tools-dropdown" role="menu" class="lh-tools__dropdown" aria-labelledby="lh-tools-button">
          <!-- TODO(i18n): localize tools dropdown -->
-        <a href="#" class="report-icon report-icon--print" data-action="print-summary">Print Summary</a>
-        <a href="#" class="report-icon report-icon--print" data-action="print-expanded">Print Expanded</a>
-        <a href="#" class="report-icon report-icon--copy" data-action="copy">Copy JSON</a>
-        <a href="#" class="report-icon report-icon--download" data-action="save-html">Save as HTML</a>
-        <a href="#" class="report-icon report-icon--download" data-action="save-json">Save as JSON</a>
-        <a href="#" class="report-icon report-icon--open lh-tools--viewer" data-action="open-viewer">Open in Viewer</a>
-        <a href="#" class="report-icon report-icon--open lh-tools--gist" data-action="save-gist">Save as Gist</a>
-        <a href="#" class="report-icon report-icon--dark" data-action="toggle-dark">Toggle Dark Theme</a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--print" data-action="print-summary">Print Summary</a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--print" data-action="print-expanded">Print Expanded</a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--copy" data-action="copy">Copy JSON</a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--download" data-action="save-html">Save as HTML</a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--download" data-action="save-json">Save as JSON</a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--open lh-tools--viewer" data-action="open-viewer">Open in Viewer</a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--open lh-tools--gist" data-action="save-gist">Save as Gist</a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--dark" data-action="toggle-dark">Toggle Dark Theme</a>
       </div>
     </div>
   </div>


Test patch:
diff --git a/lighthouse-core/test/report/html/renderer/report-ui-features-test.js b/lighthouse-core/test/report/html/renderer/report-ui-features-test.js
index 577cbb2b6..9f93127e7 100644
--- a/lighthouse-core/test/report/html/renderer/report-ui-features-test.js
+++ b/lighthouse-core/test/report/html/renderer/report-ui-features-test.js
@@ -73,6 +73,7 @@ describe('ReportUIFeatures', () => {
       };
     };
 
+    global.HTMLElement = document.window.HTMLElement;
     global.HTMLInputElement = document.window.HTMLInputElement;
 
     global.window = document.window;
@@ -99,6 +100,7 @@ describe('ReportUIFeatures', () => {
     global.PerformanceCategoryRenderer = undefined;
     global.PwaCategoryRenderer = undefined;
     global.window = undefined;
+    global.HTMLElement = undefined;
     global.HTMLInputElement = undefined;
   });
 
@@ -264,4 +266,130 @@ describe('ReportUIFeatures', () => {
       assert.ok(container.querySelector('.lh-metrics-toggle__input').checked);
     });
   });
+
+  describe('tools button', () => {
+    let window;
+    let features;
+
+    beforeEach(() => {
+      window = dom.document().defaultView;
+      features = new ReportUIFeatures(dom);
+      features.initFeatures(sampleResults);
+    });
+
+    it('click should toggle active class', () => {
+      features.toolsButton.click();
+      assert.ok(features.toolsButton.classList.contains('active'));
+
+      features.toolsButton.click();
+      assert.ok(!features.toolsButton.classList.contains('active'));
+    });
+
+
+    it('Escape key removes active class', () => {
+      features.toolsButton.click();
+      assert.ok(features.toolsButton.classList.contains('active'));
+
+      const escape = new window.KeyboardEvent('keydown', {keyCode: /* ESC */ 27});
+      dom.document().dispatchEvent(escape);
+      assert.ok(!features.toolsButton.classList.contains('active'));
+    });
+
+    ['ArrowUp', 'ArrowDown', 'Enter', ' '].forEach((code) => {
+      it(`'${code}' adds active class`, () => {
+        const event = new window.KeyboardEvent('keydown', {code});
+        features.toolsButton.dispatchEvent(event);
+        assert.ok(features.toolsButton.classList.contains('active'));
+      });
+    });
+
+    it('ArrowUp on the first menu element should focus the last element', () => {
+      features.toolsButton.click();
+
+      const arrowUp = new window.KeyboardEvent('keydown', {bubbles: true, code: 'ArrowUp'});
+      features.toolsDropDown.firstElementChild.dispatchEvent(arrowUp);
+
+      assert.strictEqual(dom.document().activeElement, features.toolsDropDown.lastElementChild);
+    });
+
+    it('ArrowDown on the first menu element should focus the second element', () => {
+      features.toolsButton.click();
+
+      const {nextElementSibling} = features.toolsDropDown.firstElementChild;
+      const arrowDown = new window.KeyboardEvent('keydown', {bubbles: true, code: 'ArrowDown'});
+      features.toolsDropDown.firstElementChild.dispatchEvent(arrowDown);
+
+      assert.strictEqual(dom.document().activeElement, nextElementSibling);
+    });
+
+    it('Home on the last menu element should focus the first element', () => {
+      features.toolsButton.click();
+
+      const {firstElementChild} = features.toolsDropDown;
+      const home = new window.KeyboardEvent('keydown', {bubbles: true, code: 'Home'});
+      features.toolsDropDown.lastElementChild.dispatchEvent(home);
+
+      assert.strictEqual(dom.document().activeElement, firstElementChild);
+    });
+
+    it('End on the first menu element should focus the last element', () => {
+      features.toolsButton.click();
+
+      const {lastElementChild} = features.toolsDropDown;
+      const end = new window.KeyboardEvent('keydown', {bubbles: true, code: 'End'});
+      features.toolsDropDown.firstElementChild.dispatchEvent(end);
+
+      assert.strictEqual(dom.document().activeElement, lastElementChild);
+    });
+
+    describe('_getNextSelectableNode', () => {
+      let createDiv;
+
+      beforeAll(() => {
+        createDiv = () => dom.document().createElement('div');
+      });
+
+      it('should return first node when start is undefined', () => {
+        const nodes = [createDiv(), createDiv()];
+
+        const nextNode = features._getNextSelectableNode(nodes);
+
+        assert.strictEqual(nextNode, nodes[0]);
+      });
+
+      it('should return second node when start is first node', () => {
+        const nodes = [createDiv(), createDiv()];
+
+        const nextNode = features._getNextSelectableNode(nodes, nodes[0]);
+
+        assert.strictEqual(nextNode, nodes[1]);
+      });
+
+      it('should return first node when start is second node', () => {
+        const nodes = [createDiv(), createDiv()];
+
+        const nextNode = features._getNextSelectableNode(nodes, nodes[1]);
+
+        assert.strictEqual(nextNode, nodes[0]);
+      });
+
+      it('should skip the undefined node', () => {
+        const nodes = [createDiv(), undefined, createDiv()];
+
+        const nextNode = features._getNextSelectableNode(nodes, nodes[0]);
+
+        assert.strictEqual(nextNode, nodes[2]);
+      });
+
+      it('should skip the disabled node', () => {
+        const disabledNode = createDiv();
+        disabledNode.setAttribute('disabled', true);
+        const nodes = [createDiv(), disabledNode, createDiv()];
+
+        const nextNode = features._getNextSelectableNode(nodes, nodes[0]);
+
+        assert.strictEqual(nextNode, nodes[2]);
+      });
+    });
+  });
 });