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]); });