Bug 1424159 - make virtualized tree nodes tabbable similar to how the inspector markup tree works. r=nchevobbe
MozReview-Commit-ID: LfSppQgpI0O Differential Revision: https://phabricator.services.mozilla.com/D18591
This commit is contained in:
@@ -8,6 +8,9 @@ const { Component, createFactory } = require("devtools/client/shared/vendor/reac
|
||||
const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
|
||||
const dom = require("devtools/client/shared/vendor/react-dom-factories");
|
||||
const { scrollIntoView } = require("devtools/client/shared/scroll");
|
||||
const { preventDefaultAndStopPropagation } = require("devtools/client/shared/events");
|
||||
|
||||
loader.lazyRequireGetter(this, "focusableSelector", "devtools/client/shared/focus", true);
|
||||
|
||||
const AUTO_EXPAND_DEPTH = 0;
|
||||
const NUMBER_OF_OFFSCREEN_ITEMS = 1;
|
||||
@@ -196,6 +199,9 @@ class Tree extends Component {
|
||||
// Handle when a new item is focused.
|
||||
onFocus: PropTypes.func,
|
||||
|
||||
// The currently active (keyboard) item, if any such item exists.
|
||||
active: PropTypes.any,
|
||||
|
||||
// Handle when item is activated with a keyboard (using Space or Enter)
|
||||
onActivate: PropTypes.func,
|
||||
|
||||
@@ -255,7 +261,6 @@ class Tree extends Component {
|
||||
this._focusParentNode = oncePerAnimationFrame(this._focusParentNode).bind(this);
|
||||
this._focusFirstNode = oncePerAnimationFrame(this._focusFirstNode).bind(this);
|
||||
this._focusLastNode = oncePerAnimationFrame(this._focusLastNode).bind(this);
|
||||
this._activateNode = oncePerAnimationFrame(this._activateNode).bind(this);
|
||||
|
||||
this._autoExpand = this._autoExpand.bind(this);
|
||||
this._preventArrowKeyScrolling = this._preventArrowKeyScrolling.bind(this);
|
||||
@@ -264,7 +269,7 @@ class Tree extends Component {
|
||||
this._dfs = this._dfs.bind(this);
|
||||
this._dfsFromRoots = this._dfsFromRoots.bind(this);
|
||||
this._focus = this._focus.bind(this);
|
||||
this._onBlur = this._onBlur.bind(this);
|
||||
this._activate = this._activate.bind(this);
|
||||
this._onKeyDown = this._onKeyDown.bind(this);
|
||||
}
|
||||
|
||||
@@ -329,16 +334,8 @@ class Tree extends Component {
|
||||
case "ArrowDown":
|
||||
case "ArrowLeft":
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.nativeEvent) {
|
||||
if (e.nativeEvent.preventDefault) {
|
||||
e.nativeEvent.preventDefault();
|
||||
}
|
||||
if (e.nativeEvent.stopPropagation) {
|
||||
e.nativeEvent.stopPropagation();
|
||||
}
|
||||
}
|
||||
preventDefaultAndStopPropagation(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,11 +437,24 @@ class Tree extends Component {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.props.active != null) {
|
||||
this._activate(null);
|
||||
if (this.refs.tree !== this.activeElement) {
|
||||
this.refs.tree.focus();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.props.onFocus) {
|
||||
this.props.onFocus(item);
|
||||
}
|
||||
}
|
||||
|
||||
_activate(item) {
|
||||
if (this.props.onActivate) {
|
||||
this.props.onActivate(item);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state height and tree's scrollTop if necessary.
|
||||
*/
|
||||
@@ -459,13 +469,6 @@ class Tree extends Component {
|
||||
this._updateHeight();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the state to have no focused item.
|
||||
*/
|
||||
_onBlur() {
|
||||
this._focus(0, undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fired on a scroll within the tree's container, updates
|
||||
* the stored position of the view port to handle virtual view rendering.
|
||||
@@ -533,15 +536,31 @@ class Tree extends Component {
|
||||
|
||||
case "Enter":
|
||||
case " ":
|
||||
this._activateNode();
|
||||
// On space or enter make focused tree node active. This means keyboard focus
|
||||
// handling is passed on to the tree node itself.
|
||||
if (this.refs.tree === this.activeElement) {
|
||||
preventDefaultAndStopPropagation(e);
|
||||
if (this.props.active !== this.props.focused) {
|
||||
this._activate(this.props.focused);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "Escape":
|
||||
preventDefaultAndStopPropagation(e);
|
||||
if (this.props.active != null) {
|
||||
this._activate(null);
|
||||
}
|
||||
|
||||
if (this.refs.tree !== this.activeElement) {
|
||||
this.refs.tree.focus();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_activateNode() {
|
||||
if (this.props.onActivate) {
|
||||
this.props.onActivate(this.props.focused);
|
||||
}
|
||||
get activeElement() {
|
||||
return this.refs.tree.ownerDocument.activeElement;
|
||||
}
|
||||
|
||||
_focusFirstNode() {
|
||||
@@ -640,7 +659,7 @@ class Tree extends Component {
|
||||
// the top and bottom of the page are filled with the `NUMBER_OF_OFFSCREEN_ITEMS`
|
||||
// previous and next items respectively, which helps the user to see fewer empty
|
||||
// gaps when scrolling quickly.
|
||||
const { itemHeight, focused } = this.props;
|
||||
const { itemHeight, active, focused } = this.props;
|
||||
const { scroll, height } = this.state;
|
||||
const begin = Math.max(((scroll / itemHeight) | 0) - NUMBER_OF_OFFSCREEN_ITEMS, 0);
|
||||
const end = Math.ceil((scroll + height) / itemHeight) + NUMBER_OF_OFFSCREEN_ITEMS;
|
||||
@@ -667,7 +686,10 @@ class Tree extends Component {
|
||||
const { item, depth } = toRender[i];
|
||||
const key = this.props.getKey(item);
|
||||
nodes.push(TreeNode({
|
||||
key,
|
||||
// We make a key unique depending on whether the tree node is in active or
|
||||
// inactive state to make sure that it is actually replaced and the tabbable
|
||||
// state is reset.
|
||||
key: `${key}-${active === item ? "active" : "inactive"}`,
|
||||
index,
|
||||
first,
|
||||
last,
|
||||
@@ -676,6 +698,7 @@ class Tree extends Component {
|
||||
id: key,
|
||||
renderItem: this.props.renderItem,
|
||||
focused: focused === item,
|
||||
active: active === item,
|
||||
expanded: this.props.isExpanded(item),
|
||||
hasChildren: !!this.props.getChildren(item).length,
|
||||
onExpand: this._onExpand,
|
||||
@@ -718,6 +741,14 @@ class Tree extends Component {
|
||||
// interarction.
|
||||
this._focus(begin, toRender[0].item);
|
||||
},
|
||||
onBlur: e => {
|
||||
if (active != null) {
|
||||
const { relatedTarget } = e;
|
||||
if (!this.refs.tree.contains(relatedTarget)) {
|
||||
this._activate(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
onClick: () => {
|
||||
// Focus should always remain on the tree container itself.
|
||||
this.refs.tree.focus();
|
||||
@@ -759,6 +790,8 @@ class ArrowExpanderClass extends Component {
|
||||
render() {
|
||||
const attrs = {
|
||||
className: "arrow theme-twisty",
|
||||
// To collapse/expand the tree rows use left/right arrow keys.
|
||||
tabIndex: "-1",
|
||||
onClick: this.props.expanded
|
||||
? () => this.props.onCollapse(this.props.item)
|
||||
: e => this.props.onExpand(this.props.item, e.altKey),
|
||||
@@ -783,6 +816,7 @@ class TreeNodeClass extends Component {
|
||||
return {
|
||||
id: PropTypes.any.isRequired,
|
||||
focused: PropTypes.bool.isRequired,
|
||||
active: PropTypes.boool.isRequired,
|
||||
item: PropTypes.any.isRequired,
|
||||
expanded: PropTypes.bool.isRequired,
|
||||
hasChildren: PropTypes.bool.isRequired,
|
||||
@@ -797,6 +831,85 @@ class TreeNodeClass extends Component {
|
||||
};
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this._onKeyDown = this._onKeyDown.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Make sure that none of the focusable elements inside the tree node container are
|
||||
// tabbable if the tree node is not active. If the tree node is active and focus is
|
||||
// outside its container, focus on the first focusable element inside.
|
||||
const elms = this.getFocusableElements();
|
||||
if (elms.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.props.active) {
|
||||
elms.forEach(elm => elm.setAttribute("tabindex", "-1"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!elms.includes(this.refs.treenode.ownerDocument.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.refs.treenode.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();
|
||||
}
|
||||
|
||||
render() {
|
||||
const arrow = ArrowExpander({
|
||||
item: this.props.item,
|
||||
@@ -816,6 +929,9 @@ class TreeNodeClass extends Component {
|
||||
if (this.props.last) {
|
||||
classList.push("tree-node-last");
|
||||
}
|
||||
if (this.props.active) {
|
||||
classList.push("tree-node-active");
|
||||
}
|
||||
|
||||
let ariaExpanded;
|
||||
if (this.props.hasChildren) {
|
||||
@@ -830,8 +946,10 @@ class TreeNodeClass extends Component {
|
||||
id: this.props.id,
|
||||
className: classList.join(" "),
|
||||
role: "treeitem",
|
||||
ref: "treenode",
|
||||
"aria-level": this.props.depth + 1,
|
||||
onClick: this.props.onClick,
|
||||
onKeyDownCapture: this.props.active && this._onKeyDown,
|
||||
"aria-expanded": ariaExpanded,
|
||||
"data-expanded": this.props.expanded ? "" : undefined,
|
||||
"data-depth": this.props.depth,
|
||||
|
||||
Reference in New Issue
Block a user