Bug 1538281 - make tree view row keyboard navigation consistent with other shared components. r=nchevobbe

Differential Revision: https://phabricator.services.mozilla.com/D24538

--HG--
extra : moz-landing-system : lando
This commit is contained in:
Yura Zenevich 2019-04-04 14:12:37 +00:00
Родитель 5fa2d6f5cf
Коммит 663f9a19bc
6 изменённых файлов: 544 добавлений и 26 удалений

Просмотреть файл

@ -38,3 +38,4 @@ support-files =
[test_tree_14.html]
[test_tree_15.html]
[test_tree_16.html]
[test_tree-view_01.html]

Просмотреть файл

@ -65,6 +65,53 @@ function dumpn(msg) {
dump(`SHARED-COMPONENTS-TEST: ${msg}\n`);
}
/**
* Tree View
*/
const TEST_TREE_VIEW = {
A: { label: "A", value: "A" },
B: { label: "B", value: "B" },
C: { label: "C", value: "C" },
D: { label: "D", value: "D" },
E: { label: "E", value: "E" },
F: { label: "F", value: "F" },
G: { label: "G", value: "G" },
H: { label: "H", value: "H" },
I: { label: "I", value: "I" },
J: { label: "J", value: "J" },
K: { label: "K", value: "K" },
L: { label: "L", value: "L" },
};
TEST_TREE_VIEW.children = {
A: [TEST_TREE_VIEW.B, TEST_TREE_VIEW.C, TEST_TREE_VIEW.D],
B: [TEST_TREE_VIEW.E, TEST_TREE_VIEW.F, TEST_TREE_VIEW.G],
C: [TEST_TREE_VIEW.H, TEST_TREE_VIEW.I],
D: [TEST_TREE_VIEW.J],
E: [TEST_TREE_VIEW.K, TEST_TREE_VIEW.L],
F: [],
G: [],
H: [],
I: [],
J: [],
K: [],
L: [],
};
const TEST_TREE_VIEW_INTERFACE = {
provider: {
getChildren: x => TEST_TREE_VIEW.children[x.label],
hasChildren: x => TEST_TREE_VIEW.children[x.label].length > 0,
getLabel: x => x.label,
getValue: x => x.value,
getKey: x => x.label,
getType: () => "string",
},
object: TEST_TREE_VIEW.A,
columns: [{ id: "default" }, { id: "value" }],
};
/**
* Tree
*/

Просмотреть файл

@ -0,0 +1,282 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<!DOCTYPE HTML>
<html>
<!--
Test that TreeView component has working keyboard interactions.
-->
<head>
<meta charset="utf-8">
<title>TreeView component keyboard test</title>
<script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
<script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script>
<link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
<link rel="stylesheet" href="chrome://devtools/skin/light-theme.css" type="text/css">
</head>
<body>
<pre id="test">
<script src="head.js" type="application/javascript"></script>
<script type="application/javascript">
"use strict";
window.onload = function() {
try {
const { a, button, div } =
require("devtools/client/shared/vendor/react-dom-factories");
const React = browserRequire("devtools/client/shared/vendor/react");
const {
Simulate,
findRenderedDOMComponentWithClass,
findRenderedDOMComponentWithTag,
scryRenderedDOMComponentsWithClass,
} = browserRequire("devtools/client/shared/vendor/react-dom-test-utils");
const TreeView =
browserRequire("devtools/client/shared/components/tree/TreeView");
const props = {
...TEST_TREE_VIEW_INTERFACE,
renderValue: props => {
return (props.value === "C" ?
div({},
props.value + " ",
a({ href: "#" }, "Focusable 1"),
button({ }, "Focusable 2")) :
props.value + ""
);
},
};
const treeView = React.createElement(TreeView, props);
const tree = ReactDOM.render(treeView, document.body);
const treeViewEl = findRenderedDOMComponentWithClass(tree, "treeTable");
const rows = scryRenderedDOMComponentsWithClass(tree, "treeRow");
const defaultFocus = treeViewEl.ownerDocument.body;
function blurEl(el) {
// Simulate.blur does not seem to update the activeElement.
el.blur();
}
function focusEl(el) {
// Simulate.focus does not seem to update the activeElement.
el.focus();
}
const tests = [{
name: "Test default TreeView state. Keyboard focus is set to document " +
"body by default.",
state: { selected: null, active: null },
activeElement: defaultFocus,
}, {
name: "Selected row must be set to the first row on initial focus. " +
"Keyboard focus should be set on TreeView's conatiner.",
action: () => {
focusEl(treeViewEl);
Simulate.click(rows[0]);
},
activeElement: treeViewEl,
state: { selected: "/B" },
}, {
name: "Selected row should remain set even when the treeView is " +
"blured. Keyboard focus should be set back to document body.",
action: () => blurEl(treeViewEl),
state: { selected: "/B" },
activeElement: defaultFocus,
}, {
name: "Selected row must be re-set again to the first row on initial " +
"focus. Keyboard focus should be set on treeView's conatiner.",
action: () => focusEl(treeViewEl),
activeElement: treeViewEl,
state: { selected: "/B" },
}, {
name: "Selected row should be updated to next on ArrowDown.",
event: { type: "keyDown", el: treeViewEl, options: { key: "ArrowDown" }},
state: { selected: "/C" },
}, {
name: "Selected row should be updated to last on ArrowDown.",
event: { type: "keyDown", el: treeViewEl, options: { key: "ArrowDown" }},
state: { selected: "/D" },
}, {
name: "Selected row should remain on last on ArrowDown.",
event: { type: "keyDown", el: treeViewEl, options: { key: "ArrowDown" }},
state: { selected: "/D" },
}, {
name: "Selected row should be updated to previous on ArrowUp.",
event: { type: "keyDown", el: treeViewEl, options: { key: "ArrowUp" }},
state: { selected: "/C" },
}, {
name: "Selected row should be updated to first on ArrowUp.",
event: { type: "keyDown", el: treeViewEl, options: { key: "ArrowUp" }},
state: { selected: "/B" },
}, {
name: "Selected row should remain on first on ArrowUp.",
event: { type: "keyDown", el: treeViewEl, options: { key: "ArrowUp" }},
state: { selected: "/B" },
}, {
name: "Selected row should be updated to last on End.",
event: { type: "keyDown", el: treeViewEl, options: { key: "End" }},
state: { selected: "/D" },
}, {
name: "Selected row should be updated to first on Home.",
event: { type: "keyDown", el: treeViewEl, options: { key: "Home" }},
state: { selected: "/B" },
}, {
name: "Selected row should be set as active on Enter.",
event: { type: "keyDown", el: treeViewEl, options: { key: "Enter" }},
state: { selected: "/B", active: "/B" },
activeElement: treeViewEl,
}, {
name: "Active row should be unset on Escape.",
event: { type: "keyDown", el: treeViewEl, options: { key: "Escape" }},
state: { selected: "/B", active: null },
}, {
name: "Selected row should be set as active on Space.",
event: { type: "keyDown", el: treeViewEl, options: { key: " " }},
state: { selected: "/B", active: "/B" },
activeElement: treeViewEl,
}, {
name: "Selected row should unset when focus leaves the treeView.",
action: () => blurEl(treeViewEl),
state: { selected: "/B", active: null },
activeElement: defaultFocus,
}, {
name: "Keyboard focus should be set on treeView's conatiner on focus.",
action: () => focusEl(treeViewEl),
activeElement: treeViewEl,
}, {
name: "Selected row should be updated to next on ArrowDown.",
event: { type: "keyDown", el: treeViewEl, options: { key: "ArrowDown" }},
state: { selected: "/C", active: null },
}, {
name: "Selected row should be set as active on Enter. Keyboard focus " +
"should be set on the first focusable element inside the row, if " +
"available.",
event: { type: "keyDown", el: treeViewEl, options: { key: "Enter" }},
state: { selected: "/C", active: "/C" },
get activeElement() {
// When row becomes active/inactive, it is replaced with a newly
// rendered one.
return findRenderedDOMComponentWithTag(tree, "a");
},
}, {
name: "Keyboard focus should be set to next tabbable element inside " +
"the active row on Tab.",
action() {
synthesizeKey("KEY_Tab");
},
state: { selected: "/C", active: "/C" },
get activeElement() {
// When row becomes active/inactive, it is replaced with a newly
// rendered one.
return findRenderedDOMComponentWithTag(tree, "button");
},
}, {
name: "Keyboard focus should wrap inside the row when focused on last " +
"tabbable element.",
action() {
synthesizeKey("KEY_Tab");
},
state: { selected: "/C", active: "/C" },
get activeElement() {
return findRenderedDOMComponentWithTag(tree, "a");
},
}, {
name: "Keyboard focus should wrap inside the row when focused on first " +
"tabbable element.",
action() {
synthesizeKey("KEY_Tab", { shiftKey: true });
},
state: { selected: "/C", active: "/C" },
get activeElement() {
return findRenderedDOMComponentWithTag(tree, "button");
},
}, {
name: "Active row should be unset on Escape. Focus should move back to " +
"the treeView container.",
event: { type: "keyDown", el: treeViewEl, options: { key: "Escape" }},
state: { selected: "/C", active: null },
activeElement: treeViewEl,
}, {
name: "Selected row should be set as active on Space. Keyboard focus " +
"should be set on the first focusable element inside the row, if " +
"available.",
event: { type: "keyDown", el: treeViewEl, options: { key: " " }},
state: { selected: "/C", active: "/C" },
get activeElement() {
// When row becomes active/inactive, it is replaced with a newly
// rendered one.
return findRenderedDOMComponentWithTag(tree, "a");
},
}, {
name: "Selected row should remain set even when the treeView is " +
"blured. Keyboard focus should be set back to document body.",
action: () => treeViewEl.ownerDocument.activeElement.blur(),
state: { selected: "/C", active: null },
activeElement: defaultFocus,
}, {
name: "Keyboard focus should be set on treeView's conatiner on focus.",
action: () => focusEl(treeViewEl),
state: { selected: "/C", active: null },
activeElement: treeViewEl,
}, {
name: "Selected row should be set as active on Space. Keyboard focus " +
"should be set on the first focusable element inside the row, if " +
"available.",
event: { type: "keyDown", el: treeViewEl, options: { key: " " }},
state: { selected: "/C", active: "/C" },
get activeElement() {
// When row becomes active/inactive, it is replaced with a newly
// rendered one.
return findRenderedDOMComponentWithTag(tree, "a");
},
}, {
name: "Selected row should be updated to previous on ArrowUp.",
event: { type: "keyDown", el: treeViewEl, options: { key: "ArrowUp" }},
state: { selected: "/B", active: null },
activeElement: treeViewEl,
}, {
name: "Selected row should be set as active on Enter.",
event: { type: "keyDown", el: treeViewEl, options: { key: "Enter" }},
state: { selected: "/B", active: "/B" },
activeElement: treeViewEl,
}, {
name: "Keyboard focus should move to another focusable element outside " +
"of the treeView when there's nothing to focus on inside the row.",
action() {
synthesizeKey("KEY_Tab", { shiftKey: true });
},
state: { selected: "/B", active: null },
activeElement: treeViewEl.ownerDocument.documentElement,
}];
for (const test of tests) {
const { action, condition, event, state, name } = test;
info(name);
if (event) {
const { type, options, el } = event;
Simulate[type](el, options);
} else if (action) {
action();
}
if (test.activeElement) {
is(treeViewEl.ownerDocument.activeElement, test.activeElement,
"Focus is set correctly.");
}
for (let key in state) {
is(tree.state[key], state[key], `${key} state is correct.`);
}
}
} catch (e) {
ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e));
} finally {
SimpleTest.finish();
}
};
</script>
</pre>
</body>
</html>

Просмотреть файл

@ -7,7 +7,11 @@
// Make this available to both AMD and CJS environments
define(function(require, exports, module) {
const { Component, createFactory } = require("devtools/client/shared/vendor/react");
const {
Component,
createFactory,
createRef,
} = require("devtools/client/shared/vendor/react");
const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
const dom = require("devtools/client/shared/vendor/react-dom-factories");
const { findDOMNode } = require("devtools/client/shared/vendor/react-dom");
@ -19,6 +23,17 @@ define(function(require, exports, module) {
// Scroll
const { scrollIntoViewIfNeeded } = require("devtools/client/shared/scroll");
const { focusableSelector } = require("devtools/client/shared/focus");
const UPDATE_ON_PROPS = [
"name",
"open",
"value",
"loading",
"selected",
"active",
"hasChildren",
];
/**
* This template represents a node in TreeView component. It's rendered
@ -41,6 +56,7 @@ define(function(require, exports, module) {
path: PropTypes.string.isRequired,
hidden: PropTypes.bool,
selected: PropTypes.bool,
active: PropTypes.bool,
}),
decorator: PropTypes.object,
renderCell: PropTypes.object,
@ -57,7 +73,27 @@ define(function(require, exports, module) {
constructor(props) {
super(props);
this.treeRowRef = createRef();
this.getRowClass = this.getRowClass.bind(this);
this._onKeyDown = this._onKeyDown.bind(this);
}
componentDidMount() {
this._setTabbableState();
// Child components might add/remove new focusable elements, watch for the
// additions/removals of descendant nodes and update focusable state.
const win = this.treeRowRef.current.ownerDocument.defaultView;
const { MutationObserver } = win;
this.observer = new MutationObserver(() => {
this._setTabbableState();
});
this.observer.observe(this.treeRowRef.current, {
childList: true,
subtree: true,
});
}
componentWillReceiveProps(nextProps) {
@ -78,9 +114,8 @@ define(function(require, exports, module) {
* This makes the rendering a lot faster!
*/
shouldComponentUpdate(nextProps) {
const props = ["name", "open", "value", "loading", "selected", "hasChildren"];
for (const p in props) {
if (nextProps.member[props[p]] != this.props.member[props[p]]) {
for (const prop of UPDATE_ON_PROPS) {
if (nextProps.member[prop] != this.props.member[prop]) {
return true;
}
}
@ -99,6 +134,88 @@ define(function(require, exports, module) {
}
}
componentWillUnmount() {
this.observer.disconnect();
this.observer = null;
}
/**
* Makes sure that none of the focusable elements inside the row container
* are tabbable if the row is not active. If the row is active and focus
* is outside its container, focus on the first focusable element inside.
*/
_setTabbableState() {
const elms = this.getFocusableElements();
if (elms.length === 0) {
return;
}
const { active } = this.props.member;
if (!active) {
elms.forEach(elm => elm.setAttribute("tabindex", "-1"));
return;
}
if (!elms.includes(document.activeElement)) {
elms[0].focus();
}
}
/**
* Get a list of all elements that are focusable with a keyboard inside the
* tree node.
*/
getFocusableElements() {
return Array.from(this.treeRowRef.current.querySelectorAll(focusableSelector));
}
/**
* Wrap and move keyboard focus to first/last focusable element inside the
* tree node to prevent the focus from escaping the tree node boundaries.
* element).
*
* @param {DOMNode} current currently focused element
* @param {Boolean} back direction
* @return {Boolean} true there is a newly focused element.
*/
_wrapMoveFocus(current, back) {
const elms = this.getFocusableElements();
let next;
if (elms.length === 0) {
return false;
}
if (back) {
if (elms.indexOf(current) === 0) {
next = elms[elms.length - 1];
next.focus();
}
} else if (elms.indexOf(current) === elms.length - 1) {
next = elms[0];
next.focus();
}
return !!next;
}
_onKeyDown(e) {
const { target, key, shiftKey } = e;
if (key !== "Tab") {
return;
}
const focusMoved = this._wrapMoveFocus(target, shiftKey);
if (focusMoved) {
// Focus was moved to the begining/end of the list, so we need to
// prevent the default focus change that would happen here.
e.preventDefault();
}
e.stopPropagation();
}
getRowClass(object) {
const decorator = this.props.decorator;
if (!decorator || !decorator.getRowClass) {
@ -124,11 +241,13 @@ define(function(require, exports, module) {
const props = {
id: this.props.id,
ref: this.treeRowRef,
role: "treeitem",
"aria-level": member.level,
"aria-selected": !!member.selected,
onClick: this.props.onClick,
onContextMenu: this.props.onContextMenu,
onKeyDownCapture: member.active ? this._onKeyDown : undefined,
onMouseOver: this.props.onMouseOver,
onMouseOut: this.props.onMouseOut,
};

Просмотреть файл

@ -7,8 +7,12 @@
// Make this available to both AMD and CJS environments
define(function(require, exports, module) {
const { cloneElement, Component, createFactory } =
require("devtools/client/shared/vendor/react");
const {
cloneElement,
Component,
createFactory,
createRef,
} = require("devtools/client/shared/vendor/react");
const { findDOMNode } = require("devtools/client/shared/vendor/react-dom");
const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
const dom = require("devtools/client/shared/vendor/react-dom-factories");
@ -25,6 +29,9 @@ define(function(require, exports, module) {
"ArrowRight",
"End",
"Home",
"Enter",
" ",
"Escape",
];
const defaultProps = {
@ -33,6 +40,7 @@ define(function(require, exports, module) {
provider: ObjectProvider,
expandedNodes: new Set(),
selected: null,
active: null,
expandableStrings: true,
columns: [],
};
@ -111,6 +119,8 @@ define(function(require, exports, module) {
expandedNodes: PropTypes.object,
// Selected node
selected: PropTypes.string,
// The currently active (keyboard) item, if any such item exists.
active: PropTypes.string,
// Custom filtering callback
onFilter: PropTypes.func,
// Custom sorting callback
@ -190,15 +200,19 @@ define(function(require, exports, module) {
expandedNodes: props.expandedNodes,
columns: ensureDefaultColumn(props.columns),
selected: props.selected,
active: props.active,
lastSelectedIndex: 0,
};
this.treeRef = createRef();
this.toggle = this.toggle.bind(this);
this.isExpanded = this.isExpanded.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.onClickRow = this.onClickRow.bind(this);
this.getSelectedRow = this.getSelectedRow.bind(this);
this.selectRow = this.selectRow.bind(this);
this.activateRow = this.activateRow.bind(this);
this.isSelected = this.isSelected.bind(this);
this.onFilter = this.onFilter.bind(this);
this.onSort = this.onSort.bind(this);
@ -310,10 +324,31 @@ define(function(require, exports, module) {
this.selectRow(lastRow);
}
break;
case "Enter":
case " ":
// On space or enter make selected row active. This means keyboard
// focus handling is passed on to the tree row itself.
if (this.treeRef.current === document.activeElement) {
event.stopPropagation();
event.preventDefault();
if (this.state.active !== this.state.selected) {
this.activateRow(this.state.selected);
}
return;
}
break;
case "Escape":
event.stopPropagation();
if (this.state.active != null) {
this.activateRow(null);
}
break;
}
// Focus should always remain on the tree container itself.
this.tree.focus();
this.treeRef.current.focus();
event.preventDefault();
}
@ -358,17 +393,36 @@ define(function(require, exports, module) {
return;
}
this.setState(Object.assign({}, this.state, {
if (this.state.active != null) {
if (this.treeRef.current !== document.activeElement) {
this.treeRef.current.focus();
}
}
this.setState({
...this.state,
selected: row.id,
}));
active: null,
});
row.scrollIntoView(scrollOptions);
}
activateRow(active) {
this.setState({
...this.state,
active,
});
}
isSelected(nodePath) {
return nodePath === this.state.selected;
}
isActive(nodePath) {
return nodePath === this.state.active;
}
// Filtering & Sorting
/**
@ -450,6 +504,8 @@ define(function(require, exports, module) {
hidden: !this.onFilter(child),
// True if the node is selected with keyboard
selected: this.isSelected(nodePath),
// True if the node is activated with keyboard
active: this.isActive(nodePath),
};
});
}
@ -477,7 +533,7 @@ define(function(require, exports, module) {
}
const props = Object.assign({}, this.props, {
key: member.path,
key: `${member.path}-${member.active ? "active" : "inactive"}`,
member: member,
columns: this.state.columns,
id: member.path,
@ -538,12 +594,22 @@ define(function(require, exports, module) {
dom.table({
className: classNames.join(" "),
role: "tree",
ref: tree => {
this.tree = tree;
},
ref: this.treeRef,
tabIndex: 0,
onKeyDown: this.onKeyDown,
onContextMenu: onContextMenuTree && onContextMenuTree.bind(this),
onClick: () => {
// Focus should always remain on the tree container itself.
this.treeRef.current.focus();
},
onBlur: event => {
if (this.state.active != null) {
const { relatedTarget } = event;
if (!this.treeRef.current.contains(relatedTarget)) {
this.activateRow(null);
}
}
},
"aria-label": this.props.label || "",
"aria-activedescendant": this.state.selected,
cellPadding: 0,

Просмотреть файл

@ -4,16 +4,19 @@
"use strict";
// Simplied selector targetting elements that can receive the focus, full
// version at
// http://stackoverflow.com/questions/1599660/which-html-elements-can-receive-focus
// .
exports.focusableSelector = [
"a[href]:not([tabindex='-1'])",
"button:not([disabled]):not([tabindex='-1'])",
"iframe:not([tabindex='-1'])",
"input:not([disabled]):not([tabindex='-1'])",
"select:not([disabled]):not([tabindex='-1'])",
"textarea:not([disabled]):not([tabindex='-1'])",
"[tabindex]:not([tabindex='-1'])",
].join(", ");
// Make this available to both AMD and CJS environments
define(function(require, exports, module) {
// Simplied selector targetting elements that can receive the focus, full
// version at
// http://stackoverflow.com/questions/1599660/which-html-elements-can-receive-focus
// .
module.exports.focusableSelector = [
"a[href]:not([tabindex='-1'])",
"button:not([disabled]):not([tabindex='-1'])",
"iframe:not([tabindex='-1'])",
"input:not([disabled]):not([tabindex='-1'])",
"select:not([disabled]):not([tabindex='-1'])",
"textarea:not([disabled]):not([tabindex='-1'])",
"[tabindex]:not([tabindex='-1'])",
].join(", ");
});