Bug 1424159 - make virtualized tree nodes tabbable similar to how the inspector markup tree works. r=nchevobbe
authorYura Zenevich <yura.zenevich@gmail.com>
Wed, 13 Feb 2019 13:08:14 +0000
changeset 458877 fc3ca7ab93c5
parent 458876 202c1763bc84
child 458878 c98d6ecd9e7a
push id35551
push usershindli@mozilla.com
push dateWed, 13 Feb 2019 21:34:09 +0000
treeherdermozilla-central@08f794a4928e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnchevobbe
bugs1424159
milestone67.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
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
devtools/client/accessibility/accessibility.css
devtools/client/accessibility/components/Accessible.js
devtools/client/shared/components/List.js
devtools/client/shared/components/VirtualizedTree.js
devtools/client/shared/components/test/mochitest/chrome.ini
devtools/client/shared/components/test/mochitest/test_tree_12.html
devtools/client/shared/components/test/mochitest/test_tree_14.html
devtools/client/shared/events.js
devtools/client/shared/moz.build
--- a/devtools/client/accessibility/accessibility.css
+++ b/devtools/client/accessibility/accessibility.css
@@ -378,32 +378,41 @@ body {
 .accessible .tree:not(:focus) .node.focused .theme-twisty {
   fill: var(--accessibility-unfocused-tree-focused-node-twisty-fill);
 }
 
 .accessible .tree .node:not(.focused):hover {
   background-color: var(--theme-selection-background-hover);
 }
 
-.accessible .tree:focus .node.focused {
+.accessible .tree:focus .node.focused,
+.accessible .tree .tree-node-active .node.focused {
   background-color: var(--theme-selection-background);
 }
 
-.accessible .tree:focus .node.focused * {
+.accessible .tree:focus .node.focused *,
+.accessible .tree .tree-node-active .node.focused * {
   color: var(--theme-selection-color);
 }
 
-.accessible .tree:focus .node.focused .open-inspector {
+.accessible .tree:focus .node.focused .open-inspector,
+.accessible .tree .tree-node-active .node.focused .open-inspector {
   background-color: var(--grey-30);
 }
 
-.accessible .tree:focus .node.focused:hover .open-inspector {
+.accessible .tree:focus .node.focused:hover .open-inspector,
+.accessible .tree .tree-node-active .node.focused:hover .open-inspector {
   background-color: var(--theme-selection-color);
 }
 
+.accessible .tree .tree-node-active .node.focused .open-inspector:focus,
+.accessible .tree .tree-node-active .node.focused:hover .open-inspector:focus {
+  background-color: var(--grey-40);
+}
+
 .accessible .tree .arrow {
   flex-shrink: 0;
 }
 
 .accessible .tree .object-value {
   overflow: hidden;
   text-overflow: ellipsis;
 }
@@ -420,30 +429,38 @@ body {
   background-color: var(--accessible-label-background-color);
   color: var(--accessible-label-color);
   border: 1px solid var(--accessible-label-border-color);
   border-radius: 3px;
   padding: 0px 2px;
   margin-inline-start: 5px;
 }
 
-.accessible .tree:focus .node.focused .objectBox-accessible .accessible-role {
+.accessible .tree:focus .node.focused .objectBox-accessible .accessible-role,
+.accessible .tree .tree-node-active .node.focused .objectBox-accessible .accessible-role {
   background-color: var(--accessible-role-active-background-color);
   border-color: var(--accessible-role-active-border-color);
   color: var(--theme-selection-color);
 }
 
-.accessible .tree:focus .node.focused .open-accessibility-inspector {
+.accessible .tree:focus .node.focused .open-accessibility-inspector,
+.accessible .tree .tree-node-active .node.focused .open-accessibility-inspector {
   background-color: var(--grey-30);
 }
 
-.accessible .tree:focus .node.focused:hover .open-accessibility-inspector {
+.accessible .tree:focus .node.focused:hover .open-accessibility-inspector,
+.accessible .tree .tree-node-active .node.focused:hover .open-accessibility-inspector {
   background-color: var(--theme-selection-color);
 }
 
+.accessible .tree .tree-node-active .node.focused .open-accessibility-inspector:focus,
+.accessible .tree .tree-node-active .node.focused:hover .open-accessibility-inspector:focus {
+  background-color: var(--grey-40);
+}
+
 .accessible .tree .objectBox-accessible,
 .accessible .tree .objectBox-node {
   width: 100%;
   display: flex;
   align-items: center;
 }
 
 .accessible .tree .objectBox-accessible .accessible-name,
--- a/devtools/client/accessibility/components/Accessible.js
+++ b/devtools/client/accessibility/components/Accessible.js
@@ -88,16 +88,17 @@ class Accessible extends Component {
     };
   }
 
   constructor(props) {
     super(props);
 
     this.state = {
       expanded: new Set(),
+      active: null,
       focused: null,
     };
 
     this.onAccessibleInspected = this.onAccessibleInspected.bind(this);
     this.renderItem = this.renderItem.bind(this);
     this.update = this.update.bind(this);
   }
 
@@ -225,17 +226,17 @@ class Accessible extends Component {
     }
 
     await dispatch(select(walker, accessible));
 
     const { props } = this.refs;
     if (props) {
       props.refs.tree.blur();
     }
-    await this.setState({ focused: null });
+    await this.setState({ active: null, focused: null });
 
     window.emit(EVENTS.NEW_ACCESSIBLE_FRONT_INSPECTED);
   }
 
   openLink(link, e) {
     openContentLink(link);
   }
 
@@ -295,17 +296,17 @@ class Accessible extends Component {
         span({ className: "object-label" }, item.name),
         span({ className: "object-delimiter" }, ":"),
         span({ className: "object-value" }, Rep(valueProps) || "")
       )
     );
   }
 
   render() {
-    const { expanded, focused } = this.state;
+    const { expanded, active, focused } = this.state;
     const { items, parents, accessible, labelledby } = this.props;
 
     if (accessible) {
       return Tree({
         ref: "props",
         key: "accessible-properties",
         itemHeight: TREE_ROW_HEIGHT,
         getRoots: () => items,
@@ -315,27 +316,25 @@ class Accessible extends Component {
         isExpanded: item => expanded.has(item.path),
         onExpand: item => this.setExpanded(item, true),
         onCollapse: item => this.setExpanded(item, false),
         onFocus: item => {
           if (this.state.focused !== item.path) {
             this.setState({ focused: item.path });
           }
         },
-        onActivate: ({ contents }) => {
-          if (isNode(contents)) {
-            this.selectNode(this.props.DOMNode, "accessibility-keyboard");
-          } else if (isAccessible(contents)) {
-            const target = findAccessibleTarget(this.props.relations, contents.actor);
-            if (target) {
-              this.selectAccessible(target);
-            }
+        onActivate: item => {
+          if (item == null) {
+            this.setState({ active: null });
+          } else if (this.state.active !== item.path) {
+            this.setState({ active: item.path });
           }
         },
-        focused: findFocused(focused, items),
+        focused: findByPath(focused, items),
+        active: findByPath(active, items),
         renderItem: this.renderItem,
         labelledby,
       });
     }
 
     return div({ className: "info" },
                L10N.getStr("accessibility.accessible.notAvailable"));
   }
@@ -357,27 +356,31 @@ const findAccessibleTarget = (relations,
       }
     }
   }
 
   return null;
 };
 
 /**
- * Find currently focused item.
- * @param  {String} focused Key of the currently focused item.
- * @param  {Array}  items   Accessibility properties array.
- * @return {Object?}        Possibly found focused item.
+ * Find an item based on a given path.
+ * @param  {String} path
+ *         Key of the item to be looked up.
+ * @param  {Array}  items
+ *         Accessibility properties array.
+ * @return {Object?}
+ *         Possibly found item.
  */
-const findFocused = (focused, items) => {
+const findByPath = (path, items) => {
   for (const item of items) {
-    if (item.path === focused) {
+    if (item.path === path) {
       return item;
     }
-    const found = findFocused(focused, item.children);
+
+    const found = findByPath(path, item.children);
     if (found) {
       return found;
     }
   }
   return null;
 };
 
 /**
--- a/devtools/client/shared/components/List.js
+++ b/devtools/client/shared/components/List.js
@@ -9,16 +9,17 @@ const {
   createRef,
   Component,
   cloneElement,
 } = require("devtools/client/shared/vendor/react");
 const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
 const { ul, li, div } = 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);
 
 class ListItemClass extends Component {
   static get propTypes() {
     return {
       active: PropTypes.bool,
       current: PropTypes.bool,
@@ -177,18 +178,16 @@ class List extends Component {
     this.state = {
       active: null,
       current: null,
       mouseDown: false,
     };
 
     this._setCurrentItem = this._setCurrentItem.bind(this);
     this._preventArrowKeyScrolling = this._preventArrowKeyScrolling.bind(this);
-    this._preventDefaultAndStopPropagation =
-      this._preventDefaultAndStopPropagation.bind(this);
     this._onKeyDown = this._onKeyDown.bind(this);
   }
 
   shouldComponentUpdate(nextProps, nextState) {
     const { active, current, mouseDown } = this.state;
 
     return current !== nextState.current ||
            active !== nextState.active ||
@@ -196,34 +195,21 @@ class List extends Component {
   }
 
   _preventArrowKeyScrolling(e) {
     switch (e.key) {
       case "ArrowUp":
       case "ArrowDown":
       case "ArrowLeft":
       case "ArrowRight":
-        this._preventDefaultAndStopPropagation(e);
+        preventDefaultAndStopPropagation(e);
         break;
     }
   }
 
-  _preventDefaultAndStopPropagation(e) {
-    e.preventDefault();
-    e.stopPropagation();
-    if (e.nativeEvent) {
-      if (e.nativeEvent.preventDefault) {
-        e.nativeEvent.preventDefault();
-      }
-      if (e.nativeEvent.stopPropagation) {
-        e.nativeEvent.stopPropagation();
-      }
-    }
-  }
-
   /**
    * Sets the passed in item to be the current item.
    *
    * @param {null|Number} index
    *        The index of the item in to be set as current, or undefined to unset the
    *        current item.
    */
   _setCurrentItem(index = -1, options = {}) {
@@ -288,27 +274,27 @@ class List extends Component {
         this._setCurrentItem(length - 1, { alignTo: "bottom" });
         break;
 
       case "Enter":
       case " ":
         // On space or enter make current list item active. This means keyboard focus
         // handling is passed on to the component within the list item.
         if (document.activeElement === this.listRef.current) {
-          this._preventDefaultAndStopPropagation(e);
+          preventDefaultAndStopPropagation(e);
           if (active !== current) {
             this.setState({ active: current });
           }
         }
         break;
 
       case "Escape":
         // If current list item is active, make it inactive and let keyboard focusing be
         // handled normally.
-        this._preventDefaultAndStopPropagation(e);
+        preventDefaultAndStopPropagation(e);
         if (active != null) {
           this.setState({ active: null });
         }
 
         this.listRef.current.focus();
         break;
     }
   }
--- a/devtools/client/shared/components/VirtualizedTree.js
+++ b/devtools/client/shared/components/VirtualizedTree.js
@@ -3,16 +3,19 @@
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 /* eslint-env browser */
 "use strict";
 
 const { Component, createFactory } = 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 { 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;
 
 /**
  * A fast, generic, expandable and collapsible tree component.
  *
  * This tree component is fast: it can handle trees with *many* items. It only
@@ -191,16 +194,19 @@ class Tree extends Component {
       // Optional props
 
       // The currently focused item, if any such item exists.
       focused: PropTypes.any,
 
       // 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,
 
       // Indicates if pressing ArrowRight key should only expand expandable node
       // or if the selection should also move to the next node.
       preventNavigationOnArrowRight: PropTypes.bool,
 
       // The depth to which we should automatically expand new items.
@@ -250,26 +256,25 @@ class Tree extends Component {
     this._onExpand = oncePerAnimationFrame(this._onExpand).bind(this);
     this._onCollapse = oncePerAnimationFrame(this._onCollapse).bind(this);
     this._onScroll = oncePerAnimationFrame(this._onScroll).bind(this);
     this._focusPrevNode = oncePerAnimationFrame(this._focusPrevNode).bind(this);
     this._focusNextNode = oncePerAnimationFrame(this._focusNextNode).bind(this);
     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);
     this._updateHeight = this._updateHeight.bind(this);
     this._onResize = this._onResize.bind(this);
     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);
   }
 
   componentDidMount() {
     window.addEventListener("resize", this._onResize);
     this._autoExpand();
     this._updateHeight();
   }
@@ -324,26 +329,18 @@ class Tree extends Component {
   }
 
   _preventArrowKeyScrolling(e) {
     switch (e.key) {
       case "ArrowUp":
       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;
     }
   }
 
   /**
    * Updates the state's height based on clientHeight.
    */
   _updateHeight() {
     this.setState({ height: this.refs.tree.clientHeight });
@@ -435,43 +432,49 @@ class Tree extends Component {
       const treeElement = this.refs.tree;
       const element = document.getElementById(this.props.getKey(item));
       scrollIntoView(element, {
         ...options,
         container: treeElement,
       });
     }
 
+    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.
    */
   _onResize() {
     // When tree size changes without direct user action, scroll top cat get re-set to 0
     // (for example, when tree height changes via CSS rule change). We need to ensure that
     // the tree's scrollTop is in sync with the scroll state.
     if (this.state.scroll !== this.refs.tree.scrollTop) {
       this.refs.tree.scrollTo({ left: 0, top: this.state.scroll });
     }
 
     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.
    *
    * @param {Event} e
    */
   _onScroll(e) {
     this.setState({
       scroll: Math.max(this.refs.tree.scrollTop, 0),
@@ -528,25 +531,41 @@ class Tree extends Component {
         break;
 
       case "End":
         this._focusLastNode();
         break;
 
       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() {
     const traversal = this._dfsFromRoots();
     this._focus(0, traversal[0].item, { alignTo: "top" });
   }
 
   _focusLastNode() {
@@ -635,17 +654,17 @@ class Tree extends Component {
     const traversal = this._dfsFromRoots();
 
     // 'begin' and 'end' are the index of the first (at least partially) visible item
     // and the index after the last (at least partially) visible item, respectively.
     // `NUMBER_OF_OFFSCREEN_ITEMS` is removed from `begin` and added to `end` so that
     // 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;
     const toRender = traversal.slice(begin, end);
     const topSpacerHeight = begin * itemHeight;
     const bottomSpacerHeight = Math.max(traversal.length - end, 0) * itemHeight;
 
     const nodes = [
@@ -662,25 +681,29 @@ class Tree extends Component {
 
     for (let i = 0; i < toRender.length; i++) {
       const index = begin + i;
       const first = index == 0;
       const last = index == traversal.length - 1;
       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,
         item,
         depth,
         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,
         onCollapse: this._onCollapse,
         // Since the user just clicked the node, there's no need to check if
         // it should be scrolled into view.
         onClick: () => this._focus(begin + i, item, { preventAutoScroll: true }),
       }));
@@ -713,16 +736,24 @@ class Tree extends Component {
             return;
           }
 
           // Only set default focus to the first tree node if focused node is
           // not yet set and the focus event is not the result of a mouse
           // 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();
         },
         "aria-label": this.props.label,
         "aria-labelledby": this.props.labelledby,
         "aria-activedescendant": focused && this.props.getKey(focused),
         style: {
@@ -754,16 +785,18 @@ class ArrowExpanderClass extends Compone
     return this.props.item !== nextProps.item
       || this.props.visible !== nextProps.visible
       || this.props.expanded !== nextProps.expanded;
   }
 
   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),
     };
 
     if (this.props.expanded) {
       attrs.className += " open";
     }
@@ -778,30 +811,110 @@ class ArrowExpanderClass extends Compone
   }
 }
 
 class TreeNodeClass extends Component {
   static get propTypes() {
     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,
       onExpand: PropTypes.func.isRequired,
       index: PropTypes.number.isRequired,
       first: PropTypes.bool,
       last: PropTypes.bool,
       onClick: PropTypes.func,
       onCollapse: PropTypes.func.isRequired,
       depth: PropTypes.number.isRequired,
       renderItem: PropTypes.func.isRequired,
     };
   }
 
+  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,
       expanded: this.props.expanded,
       visible: this.props.hasChildren,
       onExpand: this.props.onExpand,
       onCollapse: this.props.onCollapse,
     });
@@ -811,32 +924,37 @@ class TreeNodeClass extends Component {
       classList.push("tree-node-odd");
     }
     if (this.props.first) {
       classList.push("tree-node-first");
     }
     if (this.props.last) {
       classList.push("tree-node-last");
     }
+    if (this.props.active) {
+      classList.push("tree-node-active");
+    }
 
     let ariaExpanded;
     if (this.props.hasChildren) {
       ariaExpanded = false;
     }
     if (this.props.expanded) {
       ariaExpanded = true;
     }
 
     return dom.div(
       {
         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,
         style: {
           padding: 0,
           margin: 0,
         },
       },
--- a/devtools/client/shared/components/test/mochitest/chrome.ini
+++ b/devtools/client/shared/components/test/mochitest/chrome.ini
@@ -30,8 +30,9 @@ support-files =
 [test_tree_06.html]
 [test_tree_07.html]
 [test_tree_08.html]
 [test_tree_09.html]
 [test_tree_10.html]
 [test_tree_11.html]
 [test_tree_12.html]
 [test_tree_13.html]
+[test_tree_14.html]
--- a/devtools/client/shared/components/test/mochitest/test_tree_12.html
+++ b/devtools/client/shared/components/test/mochitest/test_tree_12.html
@@ -33,22 +33,16 @@ window.onload = async function () {
         ...TEST_TREE_INTERFACE,
         onFocus: x => renderTree({ focused: x }),
         ...props
       };
 
       return ReactDOM.render(Tree(treeProps), window.document.body);
     }
 
-    const checker = Symbol();
-    let isActivated;
-    const mockFn = activated => {
-      isActivated = activated;
-    };
-
     const tree = renderTree();
 
     TEST_TREE.expanded = new Set("ABCDEFGHIJKLMNO".split(""));
 
     // Test Home key -----------------------------------------------------------
 
     info("Press Home to move to the first node.");
     renderTree({ focused: "L" });
@@ -135,122 +129,16 @@ window.onload = async function () {
       "--H:false",
       "--I:false",
       "-D:false",
       "--J:false",
       "M:false",
       "-N:false",
       "--O:true",
     ], "After the End key again, O should still be focused.");
-
-    // Test Enter key ----------------------------------------------------------
-
-    info("Press Enter to activate node, when onActivate is not passed.");
-    isActivated = checker;
-    renderTree({ focused: "L" });
-    Simulate.keyDown(document.querySelector(".tree"), { key: "Enter" });
-    await forceRender(tree);
-
-    isRenderedTree(document.body.textContent, [
-      "A:false",
-      "-B:false",
-      "--E:false",
-      "---K:false",
-      "---L:true",
-      "--F:false",
-      "--G:false",
-      "-C:false",
-      "--H:false",
-      "--I:false",
-      "-D:false",
-      "--J:false",
-      "M:false",
-      "-N:false",
-      "--O:false",
-    ], "After the Enter, L should be focused and the tree remained unchanged.");
-    ok(isActivated === checker,
-       "Since onActivate was not specified, 'isActivated' should not be set.");
-
-    info("Press Enter to activate node, when onActivate is passed.");
-    isActivated = checker;
-    renderTree({ focused: "L", onActivate: mockFn });
-    Simulate.keyDown(document.querySelector(".tree"), { key: "Enter" });
-    await forceRender(tree);
-
-    isRenderedTree(document.body.textContent, [
-      "A:false",
-      "-B:false",
-      "--E:false",
-      "---K:false",
-      "---L:true",
-      "--F:false",
-      "--G:false",
-      "-C:false",
-      "--H:false",
-      "--I:false",
-      "-D:false",
-      "--J:false",
-      "M:false",
-      "-N:false",
-      "--O:false",
-    ], "After the Enter, L should be focused and the tree remained unchanged.");
-    is(isActivated, "L", "onActivate function was called with the right node.");
-
-    // Test Space key ----------------------------------------------------------
-
-    info("Press Space to activate node, when onActivate is not passed.");
-    isActivated = checker;
-    renderTree({ focused: "K" });
-    Simulate.keyDown(document.querySelector(".tree"), { key: " " });
-    await forceRender(tree);
-
-    isRenderedTree(document.body.textContent, [
-      "A:false",
-      "-B:false",
-      "--E:false",
-      "---K:true",
-      "---L:false",
-      "--F:false",
-      "--G:false",
-      "-C:false",
-      "--H:false",
-      "--I:false",
-      "-D:false",
-      "--J:false",
-      "M:false",
-      "-N:false",
-      "--O:false",
-    ], "After the Space, K should be focused and the tree remained unchanged.");
-    ok(isActivated === checker,
-       "Since onActivate was not specified, 'isActivated' should not be set.");
-
-    info("Press Space to activate node, when onActivate is passed.");
-    isActivated = checker;
-    renderTree({ focused: "K", onActivate: mockFn });
-    Simulate.keyDown(document.querySelector(".tree"), { key: " " });
-    await forceRender(tree);
-
-    isRenderedTree(document.body.textContent, [
-      "A:false",
-      "-B:false",
-      "--E:false",
-      "---K:true",
-      "---L:false",
-      "--F:false",
-      "--G:false",
-      "-C:false",
-      "--H:false",
-      "--I:false",
-      "-D:false",
-      "--J:false",
-      "M:false",
-      "-N:false",
-      "--O:false",
-    ], "After the Space, K should be focused and the tree remained unchanged.");
-    is(isActivated, "K", "onActivate function was called with the right node.");
   } catch (e) {
     ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e));
   } finally {
     SimpleTest.finish();
   }
 };
 </script>
 </pre>
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/components/test/mochitest/test_tree_14.html
@@ -0,0 +1,245 @@
+<!-- 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 Tree component has working keyboard interactions.
+-->
+<head>
+  <meta charset="utf-8">
+  <title>Tree 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">
+</head>
+<body>
+<pre id="test">
+<script src="head.js" type="application/javascript"></script>
+<script type="application/javascript">
+
+"use strict";
+
+window.onload = async function() {
+  try {
+    const { a, button, div } =
+      require("devtools/client/shared/vendor/react-dom-factories");
+    const { createFactory } = browserRequire("devtools/client/shared/vendor/react");
+    const {
+      Simulate,
+      findRenderedDOMComponentWithClass,
+      findRenderedDOMComponentWithTag,
+      scryRenderedDOMComponentsWithTag,
+    } = browserRequire("devtools/client/shared/vendor/react-dom-test-utils");
+    const Tree = createFactory(
+      browserRequire("devtools/client/shared/components/VirtualizedTree"));
+
+    let gTree, gFocused, gActive;
+    function renderTree(props = {}) {
+      let toggle = true;
+      const treeProps = {
+        ...TEST_TREE_INTERFACE,
+        onFocus: x => {
+          gFocused = x;
+          renderTree({ focused: gFocused, active: gActive });
+        },
+        onActivate: x => {
+          gActive = x;
+          renderTree({ focused: gFocused, active: gActive });
+        },
+        renderItem: (x, depth, focused) => {
+          toggle = !toggle;
+          return toggle ?
+            (div(
+              {},
+                `${"-".repeat(depth)}${x}:${focused}`,
+                a({ href: "#" }, "Focusable 1"),
+                button({ }, "Focusable 2"),
+                "\n",
+              )
+            ) : `${"-".repeat(depth)}${x}:${focused}`;
+        },
+        ...props
+      };
+
+      gTree = ReactDOM.render(Tree(treeProps), document.body);
+    }
+
+    renderTree();
+    const els = {
+      get tree() {
+        // React will replace the tree via renderTree.
+        return findRenderedDOMComponentWithClass(gTree, "tree");
+      },
+      get anchor() {
+        // When tree node becomes active/inactive, it is replaced with a newly rendered
+        // one.
+        return findRenderedDOMComponentWithTag(gTree, "a");
+      },
+      get button() {
+        // When tree node becomes active/inactive, it is replaced with a newly rendered
+        // one.
+        return findRenderedDOMComponentWithTag(gTree, "button");
+      },
+    };
+
+    const tests = [{
+      name: "Test default Tree props. Keyboard focus is set to document body by default.",
+      props: { focused: undefined, active: undefined },
+      activeElement: document.body,
+    }, {
+      name: "Focused props must be set to the first node on initial focus. " +
+            "Keyboard focus should be set on the tree.",
+      action: () => els.tree.focus(),
+      activeElement: "tree",
+      props: { focused: "A" },
+    }, {
+      name: "Focused node should remain set even when the tree is blured. " +
+            "Keyboard focus should be set back to document body.",
+      action: () => els.tree.blur(),
+      props: { focused: "A" },
+      activeElement: document.body,
+    }, {
+      name: "Unset tree's focused prop.",
+      action: () => renderTree({ focused: null }),
+      props: { focused: null },
+    }, {
+      name: "Focused node must be re-set again to the first tree node on initial " +
+            "focus. Keyboard focus should be set on tree's conatiner.",
+      action: () => els.tree.focus(),
+      activeElement: "tree",
+      props: { focused: "A" },
+    }, {
+      name: "Focused node should be set as active on Enter.",
+      event: { type: "keyDown", el: "tree", options: { key: "Enter" }},
+      props: { focused: "A", active: "A" },
+      activeElement: "tree",
+    }, {
+      name: "Active node should be unset on Escape.",
+      event: { type: "keyDown", el: "tree", options: { key: "Escape" }},
+      props: { focused: "A", active: null },
+    }, {
+      name: "Focused node should be set as active on Space.",
+      event: { type: "keyDown", el: "tree", options: { key: " " }},
+      props: { focused: "A", active: "A" },
+      activeElement: "tree",
+    }, {
+      name: "Active node should unset when focus leaves the tree.",
+      action: () => els.tree.blur(),
+      props: { focused: "A", active: null },
+      activeElement: document.body,
+    }, {
+      name: "Keyboard focus should be set on tree's conatiner on focus.",
+      action: () => els.tree.focus(),
+      activeElement: "tree",
+    }, {
+      name: "Focused node should be updated to next on ArrowDown.",
+      event: { type: "keyDown", el: "tree", options: { key: "ArrowDown" }},
+      props: { focused: "M", active: null },
+    }, {
+      name: "Focused item should be set as active on Enter. Keyboard focus should be " +
+            "set on the first focusable element inside the tree node, if available.",
+      event: { type: "keyDown", el: "tree", options: { key: "Enter" }},
+      props: { focused: "M", active: "M" },
+      activeElement: "anchor",
+    }, {
+      name: "Keyboard focus should be set to next tabbable element inside the active " +
+            "node on Tab.",
+      action() {
+        synthesizeKey("KEY_Tab");
+      },
+      props: { focused: "M", active: "M" },
+      activeElement: "button",
+    }, {
+      name: "Keyboard focus should wrap inside the tree node when focused on last " +
+            "tabbable element.",
+      action() {
+        synthesizeKey("KEY_Tab");
+      },
+      props: { focused: "M", active: "M" },
+      activeElement: "anchor",
+    }, {
+      name: "Keyboard focus should wrap inside the tree node when focused on first " +
+            "tabbable element.",
+      action() {
+        synthesizeKey("KEY_Tab", { shiftKey: true });
+      },
+      props: { focused: "M", active: "M" },
+      activeElement: "button",
+    }, {
+      name: "Active tree node should be unset on Escape. Focus should move back to the " +
+            "tree container.",
+      event: { type: "keyDown", el: "tree", options: { key: "Escape" }},
+      props: { focused: "M", active: null },
+      activeElement: "tree",
+    }, {
+      name: "Focused node should be set as active on Space. Keyboard focus should be " +
+            "set on the first focusable element inside the tree node, if available.",
+      event: { type: "keyDown", el: "tree", options: { key: " " }},
+      props: { focused: "M", active: "M" },
+      activeElement: "anchor",
+    }, {
+      name: "Focused tree node should remain set even when the tree is blured. " +
+            "Keyboard focus should be set back to document body.",
+      action: () => document.activeElement.blur(),
+      props: { focused: "M", active: null, },
+      activeElement: document.body,
+    }, {
+      name: "Keyboard focus should be set on tree's conatiner on focus.",
+      action: () => els.tree.focus(),
+      props: { focused: "M", active: null },
+      activeElement: "tree",
+    }, {
+      name: "Focused tree node should be updated to previous on ArrowUp.",
+      event: { type: "keyDown", el: "tree", options: { key: "ArrowUp" }},
+      props: { focused: "A", active: null },
+    }, {
+      name: "Focused item should be set as active on Enter.",
+      event: { type: "keyDown", el: "tree", options: { key: "Enter" }},
+      props: { focused: "A", active: "A" },
+      activeElement: "tree",
+    }, {
+      name: "Keyboard focus should move to another focusable element outside of the " +
+            "tree when there's nothing to focus on inside the tree node.",
+      action() {
+        synthesizeKey("KEY_Tab", { shiftKey: true });
+      },
+      props: { focused: "A", active: null },
+      activeElement: document.documentElement,
+    }];
+
+    for (const test of tests) {
+      const { action, condition, event, props, name } = test;
+
+      info(name);
+      if (event) {
+        const { type, options, el } = event;
+        const target = typeof el === "string" ? els[el] : el;
+        Simulate[type](target, options);
+      } else if (action) {
+        action();
+      }
+
+      await forceRender(gTree);
+
+      if (test.activeElement) {
+        const expected = typeof test.activeElement === "string" ?
+          els[test.activeElement] : test.activeElement;
+        if (document.activeElement!==expected) {debugger;}
+        is(document.activeElement, expected, "Focus is set correctly.");
+      }
+
+      for (let key in props) {
+        is(gTree.props[key], props[key], `${key} prop is correct.`);
+      }
+    }
+  } catch (e) {
+    ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e));
+  } finally {
+    SimpleTest.finish();
+  }
+};
+</script>
+</pre>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/events.js
@@ -0,0 +1,22 @@
+/* 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/. */
+"use strict";
+
+/**
+ * Prevent event default behaviour and stop its propagation.
+ * @param  {Object} event
+ *         Event or react synthetic event.
+ */
+exports.preventDefaultAndStopPropagation = function(event) {
+  event.preventDefault();
+  event.stopPropagation();
+  if (event.nativeEvent) {
+    if (event.nativeEvent.preventDefault) {
+      event.nativeEvent.preventDefault();
+    }
+    if (event.nativeEvent.stopPropagation) {
+      event.nativeEvent.stopPropagation();
+    }
+  }
+};
--- a/devtools/client/shared/moz.build
+++ b/devtools/client/shared/moz.build
@@ -25,16 +25,17 @@ DevToolsModules(
     'browser-loader-mocks.js',
     'browser-loader.js',
     'css-angle.js',
     'curl.js',
     'demangle.js',
     'devices.js',
     'DOMHelpers.jsm',
     'enum.js',
+    'events.js',
     'file-saver.js',
     'focus.js',
     'getjson.js',
     'inplace-editor.js',
     'key-shortcuts.js',
     'keycodes.js',
     'link.js',
     'natural-sort.js',