diff --git a/lighthouse-core/report/html/renderer/performance-category-renderer.js b/lighthouse-core/report/html/renderer/performance-category-renderer.js
index 52c0975f02ff..51e2b630143e 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 94a07a6174bf..7de3a0566966 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} 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} 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/lighthouse-core/test/report/html/renderer/report-ui-features-test.js b/lighthouse-core/test/report/html/renderer/report-ui-features-test.js
index 9f93127e797e..0617e471c84e 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]);
});