Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 143 additions & 7 deletions lighthouse-core/report/html/renderer/report-ui-features.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
}

/**
Expand All @@ -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) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could reorder and fall thru these cases

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, I will get that in tomorrow.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took a closer look at this and was not able to reorder the cases to allow fall thrus, the issue I hit was ArrowUp/Down require a start element and Home/End should not have a start element.

Let me know if I missed something, thanks!

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

/**
Expand Down Expand Up @@ -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': {
Expand Down Expand Up @@ -464,7 +601,6 @@ class ReportUIFeatures {
}

this.closeToolsDropdown();
this._document.removeEventListener('keydown', this.onKeyDown);
}

_print() {
Expand Down
20 changes: 10 additions & 10 deletions lighthouse-core/report/html/templates.html
Original file line number Diff line number Diff line change
Expand Up @@ -370,22 +370,22 @@
<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>
Expand Down
128 changes: 128 additions & 0 deletions lighthouse-core/test/report/html/renderer/report-ui-features-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ describe('ReportUIFeatures', () => {
};
};

global.HTMLElement = document.window.HTMLElement;
global.HTMLInputElement = document.window.HTMLInputElement;

global.window = document.window;
Expand All @@ -99,6 +100,7 @@ describe('ReportUIFeatures', () => {
global.PerformanceCategoryRenderer = undefined;
global.PwaCategoryRenderer = undefined;
global.window = undefined;
global.HTMLElement = undefined;
global.HTMLInputElement = undefined;
});

Expand Down Expand Up @@ -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]);
});
});
});
});