зеркало из https://github.com/mozilla/gecko-dev.git
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:
Родитель
5fa2d6f5cf
Коммит
663f9a19bc
|
@ -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(", ");
|
||||
});
|
||||
|
|
Загрузка…
Ссылка в новой задаче