Repository: GoogleChrome/lighthouse
Pull request #9564 (2019-08-15 20:26:15)

report(refactor): Move drop down logic into a separate class
**Summary**
[As requested][1], this change refactors the reports drop down into its own class because it has grown to support the ARIA action menu pattern in #9433.

The new DropDown class is responsible for UI interactions with the action button and menu, including keydown handlers on the document to close the drop down on `ESC`.

The ReportUIFeatures class is still responsible for performing actions when a user activates a link in the drop down menu.

**Related Issues/PRs**
#9433

[1]: https://github.com/GoogleChrome/lighthouse/pull/9433#pullrequestreview-275209725
<!-- Provide any additional information we might need to understand the pull request -->


Patch:
diff --git a/lighthouse-core/audits/third-party-summary.js b/lighthouse-core/audits/third-party-summary.js
index e63ed3112..9aac6184e 100644
--- a/lighthouse-core/audits/third-party-summary.js
+++ b/lighthouse-core/audits/third-party-summary.js
@@ -14,25 +14,28 @@ const NetworkRecords = require('../computed/network-records.js');
 const MainThreadTasks = require('../computed/main-thread-tasks.js');
 
 const UIStrings = {
-  /** Title of a Lighthouse audit that identifies the code on the page that the user doesn't control. This is shown in a list of audits that Lighthouse generates. */
-  title: 'Third-Party Usage',
+  /** Title of a diagnostic audit that provides details about the code on a web page that the user doesn't control (referred to as "third-party code"). This descriptive title is shown to users when the amount is acceptable and no user action is required. */
+  title: 'Third-Party usage',
+  /** Title of a diagnostic audit that provides details about the code on a web page that the user doesn't control (referred to as "third-party code"). This imperative title is shown to users when there is a significant amount of page execution time caused by third-party code that should be reduced. */
+  failureTitle: 'Reduce the impact of third-party code',
   /** Description of a Lighthouse audit that identifies the code on the page that the user doesn't control. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
   description: 'Third-party code can significantly impact load performance. ' +
     'Limit the number of redundant third-party providers and try to load third-party code after ' +
     'your page has primarily finished loading. [Learn more](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/loading-third-party-javascript/).',
   /** Label for a table column that displays the name of a third-party provider that potentially links to their website. */
   columnThirdParty: 'Third-Party',
-  /** Label for a table column that displays how much time each row spent executing on the main thread, entries will be the number of milliseconds spent. */
-  columnMainThreadTime: 'Main Thread Time',
-  /** Summary text for the result of a Lighthouse audit that identifies the code on the page that the user doesn't control. This text summarizes the number of distinct entities that were found on the page. */
-  displayValue: `{itemCount, plural,
-    =1 {1 Third-Party Found}
-    other {# Third-Parties Found}
-  }`,
+  /** Label for a table column that displays how much time each row spent blocking other work on the main thread, entries will be the number of milliseconds spent. */
+  columnBlockingTime: 'Main-Thread Blocking Time',
+  /** Summary text for the result of a Lighthouse audit that identifies the code on a web page that the user doesn't control (referred to as "third-party code"). This text summarizes the number of distinct entities that were found on the page. */
+  displayValue: 'Third-party code blocked the main thread for ' +
+    `{timeInMs, number, milliseconds}\xa0ms`,
 };
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
 
+// A page passes when all third-party code blocks for less than 250 ms.
+const PASS_THRESHOLD_IN_MS = 250;
+
 /** @typedef {import("third-party-web").IEntity} ThirdPartyEntity */
 
 class ThirdPartySummary extends Audit {
@@ -43,8 +46,8 @@ class ThirdPartySummary extends Audit {
     return {
       id: 'third-party-summary',
       title: str_(UIStrings.title),
+      failureTitle: str_(UIStrings.failureTitle),
       description: str_(UIStrings.description),
-      scoreDisplayMode: Audit.SCORING_MODES.INFORMATIVE,
       requiredArtifacts: ['traces', 'devtoolsLogs'],
     };
   }
@@ -69,17 +72,18 @@ class ThirdPartySummary extends Audit {
    * @param {Array<LH.Artifacts.NetworkRequest>} networkRecords
    * @param {Array<LH.Artifacts.TaskNode>} mainThreadTasks
    * @param {number} cpuMultiplier
-   * @return {Map<ThirdPartyEntity, {mainThreadTime: number, transferSize: number}>}
+   * @return {Map<ThirdPartyEntity, {mainThreadTime: number, transferSize: number, blockingTime: number}>}
    */
   static getSummaryByEntity(networkRecords, mainThreadTasks, cpuMultiplier) {
-    /** @type {Map<ThirdPartyEntity, {mainThreadTime: number, transferSize: number}>} */
+    /** @type {Map<ThirdPartyEntity, {mainThreadTime: number, transferSize: number, blockingTime: number}>} */
     const entities = new Map();
+    const defaultEntityStat = {mainThreadTime: 0, blockingTime: 0, transferSize: 0};
 
     for (const request of networkRecords) {
       const entity = ThirdPartySummary.getEntitySafe(request.url);
       if (!entity) continue;
 
-      const entityStats = entities.get(entity) || {mainThreadTime: 0, transferSize: 0};
+      const entityStats = entities.get(entity) || {...defaultEntityStat};
       entityStats.transferSize += request.transferSize;
       entities.set(entity, entityStats);
     }
@@ -91,8 +95,14 @@ class ThirdPartySummary extends Audit {
       const entity = ThirdPartySummary.getEntitySafe(attributeableURL);
       if (!entity) continue;
 
-      const entityStats = entities.get(entity) || {mainThreadTime: 0, transferSize: 0};
-      entityStats.mainThreadTime += task.selfTime * cpuMultiplier;
+      const entityStats = entities.get(entity) || {...defaultEntityStat};
+      const taskDuration = task.selfTime * cpuMultiplier;
+      // The amount of time spent on main thread is the sum of all durations.
+      entityStats.mainThreadTime += taskDuration;
+      // The amount of time spent *blocking* on main thread is the sum of all time longer than 50ms.
+      // Note that this is not totally equivalent to the TBT definition since it fails to account for FCP,
+      // but a majority of third-party work occurs after FCP and should yield largely similar numbers.
+      entityStats.blockingTime += Math.max(taskDuration - 50, 0);
       entities.set(entity, entityStats);
     }
 
@@ -117,15 +127,10 @@ class ThirdPartySummary extends Audit {
 
     const summary = {wastedBytes: 0, wastedMs: 0};
 
-    // Sort by a combined measure of bytes + main thread time.
-    // 1KB ~= 1 ms
-    /** @param {{transferSize: number, mainThreadTime: number}} stats */
-    const computeSortValue = stats => stats.transferSize / 1024 + stats.mainThreadTime;
-
     const results = Array.from(summaryByEntity.entries())
       .map(([entity, stats]) => {
         summary.wastedBytes += stats.transferSize;
-        summary.wastedMs += stats.mainThreadTime;
+        summary.wastedMs += stats.blockingTime;
 
         return {
           entity: /** @type {LH.Audit.Details.LinkValue} */ ({
@@ -135,17 +140,19 @@ class ThirdPartySummary extends Audit {
           }),
           transferSize: stats.transferSize,
           mainThreadTime: stats.mainThreadTime,
+          blockingTime: stats.blockingTime,
         };
       })
-      .sort((a, b) => computeSortValue(b) - computeSortValue(a));
+      // Sort by blocking time first, then transfer size to break ties.
+      .sort((a, b) => (b.blockingTime - a.blockingTime) || (b.transferSize - a.transferSize));
 
     /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
       {key: 'entity', itemType: 'link', text: str_(UIStrings.columnThirdParty)},
       {key: 'transferSize', granularity: 1, itemType: 'bytes',
         text: str_(i18n.UIStrings.columnSize)},
-      {key: 'mainThreadTime', granularity: 1, itemType: 'ms',
-        text: str_(UIStrings.columnMainThreadTime)},
+      {key: 'blockingTime', granularity: 1, itemType: 'ms',
+        text: str_(UIStrings.columnBlockingTime)},
     ];
 
     if (!results.length) {
@@ -156,8 +163,11 @@ class ThirdPartySummary extends Audit {
     }
 
     return {
-      score: Number(results.length === 0),
-      displayValue: str_(UIStrings.displayValue, {itemCount: results.length}),
+      score: Number(summary.wastedMs <= PASS_THRESHOLD_IN_MS),
+      displayValue: str_(UIStrings.displayValue, {
+        itemCount: results.length,
+        timeInMs: summary.wastedMs,
+      }),
       details: Audit.makeTableDetails(headings, results, summary),
     };
   }
diff --git a/lighthouse-core/lib/i18n/locales/en-US.json b/lighthouse-core/lib/i18n/locales/en-US.json
index 096c96bc7..6727113c8 100644
--- a/lighthouse-core/lib/i18n/locales/en-US.json
+++ b/lighthouse-core/lib/i18n/locales/en-US.json
@@ -1073,8 +1073,8 @@
   "lighthouse-core/audits/themed-omnibox.js | title": {
     "message": "Sets a theme color for the address bar."
   },
-  "lighthouse-core/audits/third-party-summary.js | columnMainThreadTime": {
-    "message": "Main Thread Time"
+  "lighthouse-core/audits/third-party-summary.js | columnBlockingTime": {
+    "message": "Main-Thread Blocking Time"
   },
   "lighthouse-core/audits/third-party-summary.js | columnThirdParty": {
     "message": "Third-Party"
@@ -1083,10 +1083,13 @@
     "message": "Third-party code can significantly impact load performance. Limit the number of redundant third-party providers and try to load third-party code after your page has primarily finished loading. [Learn more](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/loading-third-party-javascript/)."
   },
   "lighthouse-core/audits/third-party-summary.js | displayValue": {
-    "message": "{itemCount, plural,\n    =1 {1 Third-Party Found}\n    other {# Third-Parties Found}\n  }"
+    "message": "Third-party code blocked the main thread for {timeInMs, number, milliseconds} ms"
+  },
+  "lighthouse-core/audits/third-party-summary.js | failureTitle": {
+    "message": "Reduce the impact of third-party code"
   },
   "lighthouse-core/audits/third-party-summary.js | title": {
-    "message": "Third-Party Usage"
+    "message": "Third-Party usage"
   },
   "lighthouse-core/audits/time-to-first-byte.js | description": {
     "message": "Time To First Byte identifies the time at which your server sends a response. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/ttfb)."
diff --git a/lighthouse-core/lib/i18n/locales/en-XL.json b/lighthouse-core/lib/i18n/locales/en-XL.json
index 278731990..220fbd3b8 100644
--- a/lighthouse-core/lib/i18n/locales/en-XL.json
+++ b/lighthouse-core/lib/i18n/locales/en-XL.json
@@ -1073,8 +1073,8 @@
   "lighthouse-core/audits/themed-omnibox.js | title": {
     "message": "Ŝét̂ś â t́ĥém̂é ĉól̂ór̂ f́ôŕ t̂h́ê ád̂d́r̂éŝś b̂ár̂."
   },
-  "lighthouse-core/audits/third-party-summary.js | columnMainThreadTime": {
-    "message": "M̂áîń T̂h́r̂éâd́ T̂ím̂é"
+  "lighthouse-core/audits/third-party-summary.js | columnBlockingTime": {
+    "message": "M̂áîń-T̂h́r̂éâd́ B̂ĺôćk̂ín̂ǵ T̂ím̂é"
   },
   "lighthouse-core/audits/third-party-summary.js | columnThirdParty": {
     "message": "T̂h́îŕd̂-Ṕâŕt̂ý"
@@ -1083,10 +1083,13 @@
     "message": "T̂h́îŕd̂-ṕâŕt̂ý ĉód̂é ĉán̂ śîǵn̂íf̂íĉán̂t́l̂ý îḿp̂áĉt́ l̂óâd́ p̂ér̂f́ôŕm̂án̂ćê. Ĺîḿît́ t̂h́ê ńûḿb̂ér̂ óf̂ ŕêd́ûńd̂án̂t́ t̂h́îŕd̂-ṕâŕt̂ý p̂ŕôv́îd́êŕŝ án̂d́ t̂ŕŷ t́ô ĺôád̂ t́ĥír̂d́-p̂ár̂t́ŷ ćôd́ê áf̂t́êŕ ŷóûŕ p̂áĝé ĥáŝ ṕr̂ím̂ár̂íl̂ý f̂ín̂íŝh́êd́ l̂óâd́îńĝ. [Ĺêár̂ń m̂ór̂é](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/loading-third-party-javascript/)."
   },
   "lighthouse-core/audits/third-party-summary.js | displayValue": {
-    "message": "{itemCount, plural,\n    =1 {1 T̂h́îŕd̂-Ṕâŕt̂ý F̂óûńd̂}\n    other {# T́ĥír̂d́-P̂ár̂t́îéŝ F́ôún̂d́}\n  }"
+    "message": "T̂h́îŕd̂-ṕâŕt̂ý ĉód̂é b̂ĺôćk̂éd̂ t́ĥé m̂áîń t̂h́r̂éâd́ f̂ór̂ {timeInMs, number, milliseconds} ḿŝ"
+  },
+  "lighthouse-core/audits/third-party-summary.js | failureTitle": {
+    "message": "R̂éd̂úĉé t̂h́ê ím̂ṕâćt̂ óf̂ t́ĥír̂d́-p̂ár̂t́ŷ ćôd́ê"
   },
   "lighthouse-core/audits/third-party-summary.js | title": {
-    "message": "T̂h́îŕd̂-Ṕâŕt̂ý Ûśâǵê"
+    "message": "T̂h́îŕd̂-Ṕâŕt̂ý ûśâǵê"
   },
   "lighthouse-core/audits/time-to-first-byte.js | description": {
     "message": "T̂ím̂é T̂ó F̂ír̂śt̂ B́ŷt́ê íd̂én̂t́îf́îéŝ t́ĥé t̂ím̂é ât́ ŵh́îćĥ ýôúr̂ śêŕv̂ér̂ śêńd̂ś â ŕêśp̂ón̂śê. [Ĺêár̂ń m̂ór̂é](https://developers.google.com/web/tools/lighthouse/audits/ttfb)."
diff --git a/lighthouse-core/lib/proto-preprocessor.js b/lighthouse-core/lib/proto-preprocessor.js
index 7f5142b95..6b7e943b4 100644
--- a/lighthouse-core/lib/proto-preprocessor.js
+++ b/lighthouse-core/lib/proto-preprocessor.js
@@ -29,10 +29,10 @@ function processForProto(lhr) {
   // 'ignore unknown fields' in the language of conversion.
   if (reportJson.configSettings) {
     // The settings that are in both proto and LHR
-    const {emulatedFormFactor, locale, onlyCategories} = reportJson.configSettings;
+    const {emulatedFormFactor, locale, onlyCategories, channel} = reportJson.configSettings;
 
     // @ts-ignore - intentionally only a subset of settings.
-    reportJson.configSettings = {emulatedFormFactor, locale, onlyCategories};
+    reportJson.configSettings = {emulatedFormFactor, locale, onlyCategories, channel};
   }
 
   // Remove runtimeError if it is NO_ERROR
diff --git a/lighthouse-core/report/html/renderer/performance-category-renderer.js b/lighthouse-core/report/html/renderer/performance-category-renderer.js
index 52c0975f0..51e2b6301 100644
--- a/lighthouse-core/report/html/renderer/performance-category-renderer.js
+++ b/lighthouse-core/report/html/renderer/performance-category-renderer.js
@@ -127,8 +127,8 @@ class PerformanceCategoryRenderer extends CategoryRenderer {
 
     // Metric descriptions toggle.
     const toggleTmpl = this.dom.cloneTemplate('#tmpl-lh-metrics-toggle', this.templateContext);
-    const toggleEl = this.dom.find('.lh-metrics-toggle', toggleTmpl);
-    metricAuditsEl.append(...toggleEl.childNodes);
+    const _toggleEl = this.dom.find('.lh-metrics-toggle', toggleTmpl);
+    metricAuditsEl.append(..._toggleEl.childNodes);
 
     const metricAudits = category.auditRefs.filter(audit => audit.group === 'metrics');
     const keyMetrics = metricAudits.filter(a => a.weight >= 3);
diff --git a/lighthouse-core/report/html/renderer/report-ui-features.js b/lighthouse-core/report/html/renderer/report-ui-features.js
index 94a07a617..7de3a0566 100644
--- a/lighthouse-core/report/html/renderer/report-ui-features.js
+++ b/lighthouse-core/report/html/renderer/report-ui-features.js
@@ -46,13 +46,11 @@ class ReportUIFeatures {
     this._document = this._dom.document();
     /** @type {ParentNode} */
     this._templateContext = this._dom.document();
+    /** @type {DropDown} */
+    this._dropDown = new DropDown(this._dom);
     /** @type {boolean} */
     this._copyAttempt = false;
     /** @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
@@ -63,20 +61,13 @@ 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.onDropDownMenuClick = this.onDropDownMenuClick.bind(this);
     this.onKeyUp = this.onKeyUp.bind(this);
     this.onChevronClick = this.onChevronClick.bind(this);
     this.collapseAllDetails = this.collapseAllDetails.bind(this);
     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);
   }
 
   /**
@@ -88,7 +79,7 @@ class ReportUIFeatures {
     this.json = report;
 
     this._setupMediaQueryListeners();
-    this._setupToolsButton();
+    this._dropDown.setup(this.onDropDownMenuClick);
     this._setupThirdPartyFilter();
     this._setUpCollapseDetailsAfterPrinting();
     this._resetUIState();
@@ -212,16 +203,6 @@ class ReportUIFeatures {
     root.classList.toggle('lh-narrow', mql.matches);
   }
 
-  _setupToolsButton() {
-    this.toolsButton = this._dom.find('.lh-tools__button', this._document);
-    this.toolsButton.addEventListener('click', this.onToolsButtonClick);
-    this.toolsButton.addEventListener('keydown', this.onToolsButtonKeydown);
-
-    this.toolsDropDown = this._dom.find('.lh-tools__dropdown', this._document);
-    this.toolsDropDown.addEventListener('click', this.onToolAction);
-    this.toolsDropDown.addEventListener('keydown', this.onToolsDropDownKeydown);
-  }
-
   _setupThirdPartyFilter() {
     // Some audits should not display the third party filter option.
     const thirdPartyFilterAuditExclusions = [
@@ -390,157 +371,13 @@ 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);
-  }
-
-  /**
-   * Click handler for tools button.
-   * @param {Event} e
-   */
-  onToolsButtonClick(e) {
-    e.preventDefault();
-    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));
-  }
-
   /**
    * Resets the state of page before capturing the page for export.
    * When the user opens the exported HTML page, certain UI elements should
    * be in their closed state (not opened) and the templates should be unstamped.
    */
   _resetUIState() {
-    this.closeToolsDropdown();
+    this._dropDown.close();
     this._dom.resetTemplates();
   }
 
@@ -548,7 +385,7 @@ class ReportUIFeatures {
    * Handler for tool button.
    * @param {Event} e
    */
-  onToolAction(e) {
+  onDropDownMenuClick(e) {
     e.preventDefault();
 
     const el = /** @type {?Element} */ (e.target);
@@ -600,23 +437,13 @@ class ReportUIFeatures {
       }
     }
 
-    this.closeToolsDropdown();
+    this._dropDown.close();
   }
 
   _print() {
     self.print();
   }
 
-  /**
-   * Keydown handler for the document.
-   * @param {KeyboardEvent} e
-   */
-  onKeyDown(e) {
-    if (e.keyCode === 27) { // ESC
-      this.closeToolsDropdown();
-    }
-  }
-
   /**
    * Keyup handler for the document.
    * @param {KeyboardEvent} e
@@ -624,7 +451,7 @@ class ReportUIFeatures {
   onKeyUp(e) {
     // Ctrl+P - Expands audit details when user prints via keyboard shortcut.
     if ((e.ctrlKey || e.metaKey) && e.keyCode === 80) {
-      this.closeToolsDropdown();
+      this._dropDown.close();
     }
   }
 
@@ -788,6 +615,196 @@ class ReportUIFeatures {
   }
 }
 
+class DropDown {
+  /**
+   * @param {DOM} dom
+   */
+  constructor(dom) {
+    /** @type {DOM} */
+    this._dom = dom;
+    /** @type {HTMLElement} */
+    this._toggleEl; // eslint-disable-line no-unused-expressions
+    /** @type {HTMLElement} */
+    this._menuEl; // eslint-disable-line no-unused-expressions
+
+    this.onDocumentKeyDown = this.onDocumentKeyDown.bind(this);
+    this.onToggleClick = this.onToggleClick.bind(this);
+    this.onToggleKeydown = this.onToggleKeydown.bind(this);
+    this.onMenuKeydown = this.onMenuKeydown.bind(this);
+
+    this._getNextMenuItem = this._getNextMenuItem.bind(this);
+    this._getNextSelectableNode = this._getNextSelectableNode.bind(this);
+    this._getPreviousMenuItem = this._getPreviousMenuItem.bind(this);
+  }
+
+  /**
+   * @param {function(MouseEvent): any} menuClickHandler
+   */
+  setup(menuClickHandler) {
+    this._toggleEl = this._dom.find('.lh-tools__button', this._dom.document());
+    this._toggleEl.addEventListener('click', this.onToggleClick);
+    this._toggleEl.addEventListener('keydown', this.onToggleKeydown);
+
+    this._menuEl = this._dom.find('.lh-tools__dropdown', this._dom.document());
+    this._menuEl.addEventListener('keydown', this.onMenuKeydown);
+    this._menuEl.addEventListener('click', menuClickHandler);
+  }
+
+  close() {
+    this._toggleEl.classList.remove('active');
+    this._toggleEl.setAttribute('aria-expanded', 'false');
+    if (this._menuEl.contains(this._dom.document().activeElement)) {
+      // Refocus on the tools button if the drop down last had focus
+      this._toggleEl.focus();
+    }
+    this._dom.document().removeEventListener('keydown', this.onDocumentKeyDown);
+  }
+
+  /**
+   * @param {HTMLElement} firstFocusElement
+   */
+  open(firstFocusElement) {
+    if (this._toggleEl.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._menuEl.addEventListener('transitionend', () => {
+        firstFocusElement.focus();
+      }, {once: true});
+    }
+
+    this._toggleEl.classList.add('active');
+    this._toggleEl.setAttribute('aria-expanded', 'true');
+    this._dom.document().addEventListener('keydown', this.onDocumentKeyDown);
+  }
+
+  /**
+   * Click handler for tools button.
+   * @param {Event} e
+   */
+  onToggleClick(e) {
+    e.preventDefault();
+    e.stopImmediatePropagation();
+
+    if (this._toggleEl.classList.contains('active')) {
+      this.close();
+    } else {
+      this.open(this._getNextMenuItem());
+    }
+  }
+
+  /**
+   * Handler for tool button.
+   * @param {KeyboardEvent} e
+   */
+  onToggleKeydown(e) {
+    switch (e.code) {
+      case 'ArrowUp':
+        e.preventDefault();
+        this.open(this._getPreviousMenuItem());
+        break;
+      case 'ArrowDown':
+      case 'Enter':
+      case ' ':
+        e.preventDefault();
+        this.open(this._getNextMenuItem());
+        break;
+      default:
+       // no op
+    }
+  }
+
+  /**
+   * Handler for tool DropDown.
+   * @param {KeyboardEvent} e
+   */
+  onMenuKeydown(e) {
+    const el = /** @type {?HTMLElement} */ (e.target);
+
+    switch (e.code) {
+      case 'ArrowUp':
+        e.preventDefault();
+        this._getPreviousMenuItem(el).focus();
+        break;
+      case 'ArrowDown':
+        e.preventDefault();
+        this._getNextMenuItem(el).focus();
+        break;
+      case 'Home':
+        e.preventDefault();
+        this._getNextMenuItem().focus();
+        break;
+      case 'End':
+        e.preventDefault();
+        this._getPreviousMenuItem().focus();
+        break;
+      default:
+       // no op
+    }
+  }
+
+  /**
+   * Keydown handler for the document.
+   * @param {KeyboardEvent} e
+   */
+  onDocumentKeyDown(e) {
+    if (e.keyCode === 27) { // ESC
+      this.close();
+    }
+  }
+
+  /**
+   * @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}
+   */
+  _getNextMenuItem(startEl) {
+    const nodes = Array.from(this._menuEl.childNodes);
+    return /** @type {HTMLElement} */ (this._getNextSelectableNode(nodes, startEl));
+  }
+
+  /**
+   * @param {?Element=} startEl
+   * @returns {HTMLElement}
+   */
+  _getPreviousMenuItem(startEl) {
+    const nodes = Array.from(this._menuEl.childNodes).reverse();
+    return /** @type {HTMLElement} */ (this._getNextSelectableNode(nodes, startEl));
+  }
+}
+
 if (typeof module !== 'undefined' && module.exports) {
   module.exports = ReportUIFeatures;
 } else {
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index b26c834e3..8507ea2a3 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -2578,12 +2578,13 @@
                     {
                         "granularity": 1.0,
                         "itemType": "ms",
-                        "key": "mainThreadTime",
-                        "text": "Main Thread Time"
+                        "key": "blockingTime",
+                        "text": "Main-Thread Blocking Time"
                     }
                 ],
                 "items": [
                     {
+                        "blockingTime": 22.918000000000006,
                         "entity": {
                             "text": "Google CDN",
                             "type": "link",
@@ -2595,15 +2596,15 @@
                 ],
                 "summary": {
                     "wastedBytes": 30174.0,
-                    "wastedMs": 82.74100000000001
+                    "wastedMs": 22.918000000000006
                 },
                 "type": "table"
             },
-            "displayValue": "1 Third-Party Found",
+            "displayValue": "Third-party code blocked the main thread for 20\u00a0ms",
             "id": "third-party-summary",
-            "score": null,
-            "scoreDisplayMode": "informative",
-            "title": "Third-Party Usage"
+            "score": 1.0,
+            "scoreDisplayMode": "binary",
+            "title": "Third-Party usage"
         },
         "time-to-first-byte": {
             "description": "Time To First Byte identifies the time at which your server sends a response. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/ttfb).",
@@ -3947,6 +3948,7 @@
         }
     },
     "configSettings": {
+        "channel": "cli",
         "emulatedFormFactor": "mobile",
         "locale": "en-US",
         "onlyCategories": null


Test patch:
diff --git a/lighthouse-core/test/audits/third-party-summary-test.js b/lighthouse-core/test/audits/third-party-summary-test.js
index 7bded9424..da4c5cacc 100644
--- a/lighthouse-core/test/audits/third-party-summary-test.js
+++ b/lighthouse-core/test/audits/third-party-summary-test.js
@@ -22,7 +22,10 @@ describe('Third party summary', () => {
 
     const results = await ThirdPartySummary.audit(artifacts, {computedCache: new Map()});
 
-    expect(results.displayValue).toBeDisplayString('2 Third-Parties Found');
+    expect(results.score).toBe(1);
+    expect(results.displayValue).toBeDisplayString(
+      'Third-party code blocked the main thread for 20 ms'
+    );
     expect(results.details.items).toEqual([
       {
         entity: {
@@ -31,6 +34,7 @@ describe('Third party summary', () => {
           url: 'https://marketingplatform.google.com/about/tag-manager/',
         },
         mainThreadTime: 104.70300000000002,
+        blockingTime: 18.186999999999998,
         transferSize: 30827,
       },
       {
@@ -40,6 +44,7 @@ describe('Third party summary', () => {
           url: 'https://www.google.com/analytics/analytics/',
         },
         mainThreadTime: 87.576,
+        blockingTime: 0,
         transferSize: 20913,
       },
     ]);
@@ -54,9 +59,12 @@ describe('Third party summary', () => {
     const settings = {throttlingMethod: 'simulate', throttling: {cpuSlowdownMultiplier: 4}};
     const results = await ThirdPartySummary.audit(artifacts, {computedCache: new Map(), settings});
 
+    expect(results.score).toBe(0);
     expect(results.details.items).toHaveLength(2);
     expect(Math.round(results.details.items[0].mainThreadTime)).toEqual(419);
+    expect(Math.round(results.details.items[0].blockingTime)).toEqual(250);
     expect(Math.round(results.details.items[1].mainThreadTime)).toEqual(350);
+    expect(Math.round(results.details.items[1].blockingTime)).toEqual(157);
   });
 
   it('be not applicable when no third parties are present', async () => {
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 9f93127e7..0617e471c 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
@@ -269,75 +269,76 @@ describe('ReportUIFeatures', () => {
 
   describe('tools button', () => {
     let window;
-    let features;
+    let dropDown;
 
     beforeEach(() => {
       window = dom.document().defaultView;
-      features = new ReportUIFeatures(dom);
+      const features = new ReportUIFeatures(dom);
       features.initFeatures(sampleResults);
+      dropDown = features._dropDown;
     });
 
     it('click should toggle active class', () => {
-      features.toolsButton.click();
-      assert.ok(features.toolsButton.classList.contains('active'));
+      dropDown._toggleEl.click();
+      assert.ok(dropDown._toggleEl.classList.contains('active'));
 
-      features.toolsButton.click();
-      assert.ok(!features.toolsButton.classList.contains('active'));
+      dropDown._toggleEl.click();
+      assert.ok(!dropDown._toggleEl.classList.contains('active'));
     });
 
 
     it('Escape key removes active class', () => {
-      features.toolsButton.click();
-      assert.ok(features.toolsButton.classList.contains('active'));
+      dropDown._toggleEl.click();
+      assert.ok(dropDown._toggleEl.classList.contains('active'));
 
       const escape = new window.KeyboardEvent('keydown', {keyCode: /* ESC */ 27});
       dom.document().dispatchEvent(escape);
-      assert.ok(!features.toolsButton.classList.contains('active'));
+      assert.ok(!dropDown._toggleEl.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'));
+        dropDown._toggleEl.dispatchEvent(event);
+        assert.ok(dropDown._toggleEl.classList.contains('active'));
       });
     });
 
     it('ArrowUp on the first menu element should focus the last element', () => {
-      features.toolsButton.click();
+      dropDown._toggleEl.click();
 
       const arrowUp = new window.KeyboardEvent('keydown', {bubbles: true, code: 'ArrowUp'});
-      features.toolsDropDown.firstElementChild.dispatchEvent(arrowUp);
+      dropDown._menuEl.firstElementChild.dispatchEvent(arrowUp);
 
-      assert.strictEqual(dom.document().activeElement, features.toolsDropDown.lastElementChild);
+      assert.strictEqual(dom.document().activeElement, dropDown._menuEl.lastElementChild);
     });
 
     it('ArrowDown on the first menu element should focus the second element', () => {
-      features.toolsButton.click();
+      dropDown._toggleEl.click();
 
-      const {nextElementSibling} = features.toolsDropDown.firstElementChild;
+      const {nextElementSibling} = dropDown._menuEl.firstElementChild;
       const arrowDown = new window.KeyboardEvent('keydown', {bubbles: true, code: 'ArrowDown'});
-      features.toolsDropDown.firstElementChild.dispatchEvent(arrowDown);
+      dropDown._menuEl.firstElementChild.dispatchEvent(arrowDown);
 
       assert.strictEqual(dom.document().activeElement, nextElementSibling);
     });
 
     it('Home on the last menu element should focus the first element', () => {
-      features.toolsButton.click();
+      dropDown._toggleEl.click();
 
-      const {firstElementChild} = features.toolsDropDown;
+      const {firstElementChild} = dropDown._menuEl;
       const home = new window.KeyboardEvent('keydown', {bubbles: true, code: 'Home'});
-      features.toolsDropDown.lastElementChild.dispatchEvent(home);
+      dropDown._menuEl.lastElementChild.dispatchEvent(home);
 
       assert.strictEqual(dom.document().activeElement, firstElementChild);
     });
 
     it('End on the first menu element should focus the last element', () => {
-      features.toolsButton.click();
+      dropDown._toggleEl.click();
 
-      const {lastElementChild} = features.toolsDropDown;
+      const {lastElementChild} = dropDown._menuEl;
       const end = new window.KeyboardEvent('keydown', {bubbles: true, code: 'End'});
-      features.toolsDropDown.firstElementChild.dispatchEvent(end);
+      dropDown._menuEl.firstElementChild.dispatchEvent(end);
 
       assert.strictEqual(dom.document().activeElement, lastElementChild);
     });
@@ -352,7 +353,7 @@ describe('ReportUIFeatures', () => {
       it('should return first node when start is undefined', () => {
         const nodes = [createDiv(), createDiv()];
 
-        const nextNode = features._getNextSelectableNode(nodes);
+        const nextNode = dropDown._getNextSelectableNode(nodes);
 
         assert.strictEqual(nextNode, nodes[0]);
       });
@@ -360,7 +361,7 @@ describe('ReportUIFeatures', () => {
       it('should return second node when start is first node', () => {
         const nodes = [createDiv(), createDiv()];
 
-        const nextNode = features._getNextSelectableNode(nodes, nodes[0]);
+        const nextNode = dropDown._getNextSelectableNode(nodes, nodes[0]);
 
         assert.strictEqual(nextNode, nodes[1]);
       });
@@ -368,7 +369,7 @@ describe('ReportUIFeatures', () => {
       it('should return first node when start is second node', () => {
         const nodes = [createDiv(), createDiv()];
 
-        const nextNode = features._getNextSelectableNode(nodes, nodes[1]);
+        const nextNode = dropDown._getNextSelectableNode(nodes, nodes[1]);
 
         assert.strictEqual(nextNode, nodes[0]);
       });
@@ -376,7 +377,7 @@ describe('ReportUIFeatures', () => {
       it('should skip the undefined node', () => {
         const nodes = [createDiv(), undefined, createDiv()];
 
-        const nextNode = features._getNextSelectableNode(nodes, nodes[0]);
+        const nextNode = dropDown._getNextSelectableNode(nodes, nodes[0]);
 
         assert.strictEqual(nextNode, nodes[2]);
       });
@@ -386,7 +387,7 @@ describe('ReportUIFeatures', () => {
         disabledNode.setAttribute('disabled', true);
         const nodes = [createDiv(), disabledNode, createDiv()];
 
-        const nextNode = features._getNextSelectableNode(nodes, nodes[0]);
+        const nextNode = dropDown._getNextSelectableNode(nodes, nodes[0]);
 
         assert.strictEqual(nextNode, nodes[2]);
       });
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index afa314985..c5bba298b 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -1481,11 +1481,11 @@
     },
     "third-party-summary": {
       "id": "third-party-summary",
-      "title": "Third-Party Usage",
+      "title": "Third-Party usage",
       "description": "Third-party code can significantly impact load performance. Limit the number of redundant third-party providers and try to load third-party code after your page has primarily finished loading. [Learn more](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/loading-third-party-javascript/).",
-      "score": null,
-      "scoreDisplayMode": "informative",
-      "displayValue": "1 Third-Party Found",
+      "score": 1,
+      "scoreDisplayMode": "binary",
+      "displayValue": "Third-party code blocked the main thread for 20 ms",
       "details": {
         "type": "table",
         "headings": [
@@ -1501,10 +1501,10 @@
             "text": "Size"
           },
           {
-            "key": "mainThreadTime",
+            "key": "blockingTime",
             "granularity": 1,
             "itemType": "ms",
-            "text": "Main Thread Time"
+            "text": "Main-Thread Blocking Time"
           }
         ],
         "items": [
@@ -1515,12 +1515,13 @@
               "url": "https://developers.google.com/speed/libraries/"
             },
             "transferSize": 30174,
-            "mainThreadTime": 82.74100000000001
+            "mainThreadTime": 82.74100000000001,
+            "blockingTime": 22.918000000000006
           }
         ],
         "summary": {
           "wastedBytes": 30174,
-          "wastedMs": 82.74100000000001
+          "wastedMs": 22.918000000000006
         }
       }
     },
@@ -5603,7 +5604,8 @@
       "lighthouse-core/audits/third-party-summary.js | displayValue": [
         {
           "values": {
-            "itemCount": 1
+            "itemCount": 1,
+            "timeInMs": 22.918000000000006
           },
           "path": "audits[third-party-summary].displayValue"
         }
@@ -5621,7 +5623,7 @@
         "audits[uses-text-compression].details.headings[1].label",
         "audits[tap-targets].details.headings[1].text"
       ],
-      "lighthouse-core/audits/third-party-summary.js | columnMainThreadTime": [
+      "lighthouse-core/audits/third-party-summary.js | columnBlockingTime": [
         "audits[third-party-summary].details.headings[2].text"
       ],
       "lighthouse-core/audits/manual/pwa-cross-browser.js | title": [