Commit 9df7b32d authored by Tiger Oakes's avatar Tiger Oakes Committed by Commit Bot

Added keyboard navigation to supersize

Allows users to navigate the tree view using the keyboard. The navigation
pattern follows the aria guide:
https://www.w3.org/TR/wai-aria-practices-1.1/examples/treeview/treeview-2/treeview-2a.html

- Pressing enter/space will expand the tree node.
- Pressing down navigates to the below tree node, up navigates to the above one.
- Pressing right will open the tree node, or go to its first child.
- Pressing left will close the tree node, or go to its parent.
- Pressing home goes to the root node, end goes to the last node on screen.
- Pressing a printable character (A-z, $, etc) will go to the node that starts
  with that character.

https://notwoods.github.io/chrome-supersize-reports/monochrome-2018-06-28/

Bug: 847599
Change-Id: I421909090ffff8f99f7ea1e851c4417cb8b26b57
Reviewed-on: https://chromium-review.googlesource.com/1119119Reviewed-by: default avatarEric Stevenson <estevenson@chromium.org>
Reviewed-by: default avataragrieve <agrieve@chromium.org>
Commit-Queue: Tiger Oakes <tigero@google.com>
Cr-Commit-Position: refs/heads/master@{#572551}
parent 71f9a006
<!DOCTYPE html>
<html lang='en'>
<html lang="en">
<!--
Copyright 2018 The Chromium Authors. All rights reserved.
Use of this source code is governed by a BSD-style license that can be
......@@ -17,9 +17,9 @@
display: grid;
grid-template-columns: auto 0;
grid-template-rows: 64px 1fr;
grid-template-areas: 'appbar options' 'symbols options';
grid-template-areas: "appbar options" "symbols options";
color: #3c4043;
font-family: 'Roboto', sans-serif;
font-family: "Roboto", sans-serif;
}
.appbar {
......@@ -46,7 +46,7 @@
.headline {
margin: 0;
font-family: 'Google Sans', Arial, sans-serif;
font-family: "Google Sans", Arial, sans-serif;
font-weight: normal;
color: #202124;
font-size: 22px;
......@@ -59,7 +59,7 @@
}
.subhead {
font-family: 'Google Sans', Arial, sans-serif;
font-family: "Google Sans", Arial, sans-serif;
font-weight: 500;
font-size: 14px;
color: #3c4043;
......@@ -83,7 +83,7 @@
border-bottom: 1px solid #dadce0;
}
[role='group'] {
[role="group"] {
padding-left: 13px;
border-left: 1px solid #dadce0;
margin-left: 10px;
......@@ -107,7 +107,7 @@
background: #f1f3f4;
}
.node::before {
content: '';
content: "";
display: inline-block;
margin: 10px;
width: 0;
......@@ -122,7 +122,7 @@
border-color: transparent transparent transparent currentColor;
transition: transform .1s ease-out;
}
[aria-expanded='true']>.node::before {
[aria-expanded="true"]>.node::before {
transform: rotate(90deg);
}
......@@ -206,9 +206,9 @@
<button class="filled-button" type="submit">Update</button>
</p>
</form>
<div class='symbols'>
<div hidden id='icons'>
<svg class='icon foldericon' height='24' width='24' fill='#5f6368'>
<div class="symbols">
<div hidden id="icons">
<svg class="icon foldericon" height="24" width="24" fill="#5f6368">
<title>Directory</title>
<path d="M9.17,6l2,2H20v10L4,18V6H9.17 M10,4H4C2.9,4,2.01,4.9,2.01,6L2,18c0,1.1,0.9,2,2,2h16c1.1,0,2-0.9,2-2V8c0-1.1-0.9-2-2-2
h-8L10,4L10,4z" />
......@@ -272,30 +272,29 @@
/>
</svg>
</div>
<template id="treefolder">
<li role="treeitem" aria-expanded="false">
<a class="node" href="#">
<a class="node" href="#" tabindex="-1">
<span class="symbol-name"></span>
<span class="size"></span>
</a>
<ul role="group" hidden></ul>
<ul role="group"></ul>
</li>
</template>
<template id="treeitem">
<li role="treeitem">
<span class="node">
<span class="node" tabindex="-1">
<span class="symbol-name"></span>
<span class="size"></span>
</span>
</li>
</template>
<main class='tree-container'>
<header class='tree-header'>
<span class='subtitle'>Name</span>
<span class='subtitle'>{{size_header}}</span>
<main class="tree-container">
<header class="tree-header">
<span class="subtitle">Name</span>
<span class="subtitle" id="size-header">{{size_header}}</span>
</header>
<ul id='symboltree' class='tree' role='tree' aria-labelledby='headline'></ul>
<ul id="symboltree" class="tree" role="tree" aria-labelledby="headline"></ul>
</main>
</div>
</body>
......
......@@ -28,11 +28,11 @@ const dom = {
* Removes all the existing children of `parent` and inserts
* `newChild` in their place
* @param {Node} parent
* @param {Node} newChild
* @param {Node | null} newChild
*/
replace(parent, newChild) {
while (parent.firstChild) parent.removeChild(parent.firstChild);
parent.appendChild(newChild);
if (newChild != null) parent.appendChild(newChild);
},
};
......
......@@ -48,6 +48,11 @@
const _leafTemplate = document.getElementById('treeitem');
const _treeTemplate = document.getElementById('treefolder');
const _symbolTree = document.getElementById('symboltree');
/** HTMLCollection of all nodes. Updates itself automatically. */
const _liveNodeList = document.getElementsByClassName('node');
/**
* @type {WeakMap<HTMLElement, Readonly<TreeNode>>}
* Associates UI nodes with the corresponding tree data object
......@@ -112,6 +117,24 @@
}
}
/**
* Sets focus to a new tree element while updating the element that last had
* focus. The tabindex property is used to avoid needing to tab through every
* single tree item in the page to reach other areas.
* @param {number | HTMLElement} el Index of tree node in `_liveNodeList`
*/
function _focusTreeElement(el) {
const lastFocused = document.activeElement;
if (_uiNodeData.has(lastFocused)) {
lastFocused.tabIndex = -1;
}
const element = typeof el === 'number' ? _liveNodeList[el] : el;
if (element != null) {
element.tabIndex = 0;
element.focus();
}
}
/**
* Click event handler to expand or close the child group of a tree.
* @param {Event} event
......@@ -126,17 +149,115 @@
const isExpanded = element.getAttribute('aria-expanded') === 'true';
if (isExpanded) {
element.setAttribute('aria-expanded', 'false');
group.setAttribute('hidden', '');
dom.replace(group, null);
} else {
if (group.children.length === 0) {
const data = _uiNodeData.get(link);
group.appendChild(
dom.createFragment(data.children.map(child => newTreeElement(child)))
);
const data = _uiNodeData.get(link);
group.appendChild(
dom.createFragment(data.children.map(child => newTreeElement(child)))
);
element.setAttribute('aria-expanded', 'true');
}
}
/**
* Keydown event handler to move focus for the given element
* @param {KeyboardEvent} event
*/
function _handleKeyNavigation(event) {
const link = event.target;
const focusIndex = Array.prototype.indexOf.call(_liveNodeList, link);
/** Focus the tree element immediately following this one */
function _focusNext() {
if (focusIndex > -1 && focusIndex < _liveNodeList.length - 1) {
event.preventDefault();
_focusTreeElement(focusIndex + 1);
}
}
element.setAttribute('aria-expanded', 'true');
group.removeAttribute('hidden');
/** Open or close the tree element */
function _toggle() {
event.preventDefault();
link.click();
}
/** Focus the tree element at `index` if it starts with `char` */
function _focusIfStartsWith(char, index) {
const data = _uiNodeData.get(_liveNodeList[index]);
if (data.shortName.startsWith(char)) {
event.preventDefault();
_focusTreeElement(index);
return true;
} else {
return false;
}
}
switch (event.key) {
// Space should act like clicking or pressing enter & toggle the tree.
case ' ':
_toggle();
break;
// Move to previous focusable node
case 'ArrowUp':
if (focusIndex > 0) {
event.preventDefault();
_focusTreeElement(focusIndex - 1);
}
break;
// Move to next focusable node
case 'ArrowDown':
_focusNext();
break;
// If closed tree, open tree. Otherwise, move to first child.
case 'ArrowRight':
if (_uiNodeData.get(link).children.length !== 0) {
const isExpanded =
link.parentElement.getAttribute('aria-expanded') === 'true';
if (isExpanded) {
_focusNext();
} else {
_toggle();
}
}
break;
// If opened tree, close tree. Otherwise, move to parent.
case 'ArrowLeft':
{
const isExpanded =
link.parentElement.getAttribute('aria-expanded') === 'true';
if (isExpanded) {
_toggle();
} else {
const groupList = link.parentElement.parentElement;
if (groupList.getAttribute('role') === 'group') {
event.preventDefault();
_focusTreeElement(groupList.previousElementSibling);
}
}
}
break;
// Focus first node
case 'Home':
event.preventDefault();
_focusTreeElement(0);
break;
// Focus last node on screen
case 'End':
event.preventDefault();
_focusTreeElement(_liveNodeList.length - 1);
break;
// If a letter was pressed, find a node starting with that character.
default:
if (event.key.length === 1 && event.key.match(/\S/)) {
for (let i = focusIndex + 1; i < _liveNodeList.length; i++) {
if (_focusIfStartsWith(event.key, i)) return;
}
for (let i = 0; i < focusIndex; i++) {
if (_focusIfStartsWith(event.key, i)) return;
}
}
break;
}
}
......@@ -190,7 +311,10 @@
}
});
_symbolTree.addEventListener('keydown', _handleKeyNavigation);
self.newTreeElement = newTreeElement;
self._symbolTree = _symbolTree;
}
{
......@@ -208,10 +332,12 @@
*/
worker.onmessage = ({data}) => {
const root = newTreeElement(data);
const node = root.querySelector('.node');
// Expand the root UI node
root.querySelector('.node').click();
node.click();
node.tabIndex = 0;
dom.replace(document.getElementById('symboltree'), root);
dom.replace(_symbolTree, root);
};
/**
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment