Bug 1518487 - implement simple accessible list component similar to the one used in debugger.html and memory panel. r=nchevobbe
authorYura Zenevich <yura.zenevich@gmail.com>
Tue, 12 Feb 2019 19:39:59 +0000
changeset 458787 8de6b5e7abb3
parent 458786 4923cfe749d5
child 458788 b8b41d218866
push id35548
push useropoprus@mozilla.com
push dateWed, 13 Feb 2019 09:48:26 +0000
treeherdermozilla-central@93e37c529818 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnchevobbe
bugs1518487
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 1518487 - implement simple accessible list component similar to the one used in debugger.html and memory panel. r=nchevobbe MozReview-Commit-ID: 2KCXrB9zCr0 Differential Revision: https://phabricator.services.mozilla.com/D19052
devtools/client/shared/components/List.css
devtools/client/shared/components/List.js
devtools/client/shared/components/moz.build
devtools/client/shared/components/test/mochitest/chrome.ini
devtools/client/shared/components/test/mochitest/test_list.html
devtools/client/shared/components/test/mochitest/test_list_keyboard.html
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/components/List.css
@@ -0,0 +1,41 @@
+/* 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/. */
+
+/* List */
+
+.list {
+  background-color: var(--theme-sidebar-background);
+  list-style-type: none;
+  padding: 0;
+  margin: 0;
+  width: 100%;
+  white-space: nowrap;
+  overflow: auto;
+}
+
+.list:focus, .list .list-item-content:focus {
+  outline: 0;
+}
+
+.list::-moz-focus-inner, .list .list-item-content::-moz-focus-inner {
+  border: 0;
+}
+
+.list li.current {
+  background-color: var(--theme-toolbar-hover);
+}
+
+.list:focus li.current, .list li.active.current {
+  background-color: var(--theme-emphasized-splitter-color);
+}
+
+.list:focus li:not(.current):hover,
+.list:not(:focus) li:not(.active):hover {
+  background-color: var(--theme-selection-background-hover);
+}
+
+.list .list-item-content:not(:empty) {
+  font-size: 12px;
+  overflow: auto;
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/components/List.js
@@ -0,0 +1,378 @@
+/* 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";
+
+const {
+  createFactory,
+  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");
+
+loader.lazyRequireGetter(this, "focusableSelector", "devtools/client/shared/focus", true);
+
+class ListItemClass extends Component {
+  static get propTypes() {
+    return {
+      active: PropTypes.bool,
+      current: PropTypes.bool,
+      onClick: PropTypes.func,
+      item: PropTypes.shape({
+        component: PropTypes.object,
+        componentProps: PropTypes.object,
+        className: PropTypes.string,
+      }).isRequired,
+    };
+  }
+
+  constructor(props) {
+    super(props);
+
+    this.contentRef = createRef();
+
+    this._setTabbableState = this._setTabbableState.bind(this);
+    this._onKeyDown = this._onKeyDown.bind(this);
+    this._wrapMoveFocus = this._wrapMoveFocus.bind(this);
+  }
+
+  componentDidMount() {
+    this._setTabbableState();
+  }
+
+  componentDidUpdate() {
+    this._setTabbableState();
+  }
+
+  /**
+   * Get a list of all elements that are focusable with a keyboard inside the list item.
+   */
+  getFocusableElements() {
+    return Array.from(this.contentRef.current.querySelectorAll(focusableSelector));
+  }
+
+  /**
+   * Wrap and move keyboard focus to first/last focusable element inside the list item to
+   * prevent the focus from escaping the list item container.
+   * 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(event) {
+    const { target, key, shiftKey } = event;
+
+    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.
+      event.preventDefault();
+    }
+
+    event.stopPropagation();
+  }
+
+  /**
+   * Makes sure that none of the focusable elements inside the list item container are
+   * tabbable if the list item is not active. If the list item 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;
+    }
+
+    if (!this.props.active) {
+      elms.forEach(elm => elm.setAttribute("tabindex", "-1"));
+      return;
+    }
+
+    if (!elms.includes(document.activeElement)) {
+      elms[0].focus();
+    }
+  }
+
+  render() {
+    const { active, item, current, onClick } = this.props;
+    const { className, component, componentProps } = item;
+
+    return (
+      li({
+        className: `${className}${current ? " current" : ""}${active ? " active" : ""}`,
+        id: item.key,
+        onClick,
+        onKeyDownCapture: active && this._onKeyDown,
+      },
+        div({
+          className: "list-item-content",
+          role: "presentation",
+          ref: this.contentRef,
+        }, cloneElement(component, componentProps || {}))
+      )
+    );
+  }
+}
+
+const ListItem = createFactory(ListItemClass);
+
+class List extends Component {
+  static get propTypes() {
+    return {
+      // A list of all items to be rendered using a List component.
+      items: PropTypes.arrayOf(PropTypes.shape({
+        component: PropTypes.object,
+        componentProps: PropTypes.object,
+        className: PropTypes.string,
+        key: PropTypes.string.isRequired,
+      })).isRequired,
+
+      // Note: the two properties below are mutually exclusive. Only one of the
+      // label properties is necessary.
+      // ID of an element whose textual content serves as an accessible label for
+      // a list.
+      labelledBy: PropTypes.string,
+
+      // Accessibility label for a list widget.
+      label: PropTypes.string,
+    };
+  }
+
+  constructor(props) {
+    super(props);
+
+    this.listRef = createRef();
+
+    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 ||
+           mouseDown === nextState.mouseDown;
+  }
+
+  _preventArrowKeyScrolling(e) {
+    switch (e.key) {
+      case "ArrowUp":
+      case "ArrowDown":
+      case "ArrowLeft":
+      case "ArrowRight":
+        this._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 = {}) {
+    const item = this.props.items[index];
+    if (item !== undefined && !options.preventAutoScroll) {
+      const element = document.getElementById(item.key);
+      scrollIntoView(element, {
+        ...options,
+        container: this.listRef.current,
+      });
+    }
+
+    const state = {};
+    if (this.state.active != undefined) {
+      state.active = null;
+      if (this.listRef.current !== document.activeElement) {
+        this.listRef.current.focus();
+      }
+    }
+
+    if (this.state.current !== index) {
+      this.setState({
+        ...state,
+        current: index,
+      });
+    }
+  }
+
+  /**
+   * Handles key down events in the list's container.
+   *
+   * @param {Event} e
+   */
+  _onKeyDown(e) {
+    const { active, current } = this.state;
+    if (current == null) {
+      return;
+    }
+
+    if (e.altKey || e.ctrlKey || e.shiftKey || e.metaKey) {
+      return;
+    }
+
+    this._preventArrowKeyScrolling(e);
+
+    const { length } = this.props.items;
+    switch (e.key) {
+      case "ArrowUp":
+        (current > 0) && this._setCurrentItem(current - 1, { alignTo: "top" });
+        break;
+
+      case "ArrowDown":
+        (current < length - 1) && this._setCurrentItem(
+          current + 1, { alignTo: "bottom" });
+        break;
+
+      case "Home":
+        this._setCurrentItem(0, { alignTo: "top" });
+        break;
+
+      case "End":
+        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);
+          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);
+        if (active != null) {
+          this.setState({ active: null });
+        }
+
+        this.listRef.current.focus();
+        break;
+    }
+  }
+
+  render() {
+    const { active, current } = this.state;
+    const { items } = this.props;
+
+    return (
+      ul({
+        ref: this.listRef,
+        className: "list",
+        tabIndex: 0,
+        onKeyDown: this._onKeyDown,
+        onKeyPress: this._preventArrowKeyScrolling,
+        onKeyUp: this._preventArrowKeyScrolling,
+        onMouseDown: () => this.setState({ mouseDown: true }),
+        onMouseUp: () => this.setState({ mouseDown: false }),
+        onFocus: () => {
+          if (current != null || this.state.mouseDown) {
+            return;
+          }
+
+          // Only set default current to the first list item if current item is
+          // not yet set and the focus event is not the result of a mouse
+          // interarction.
+          this._setCurrentItem(0);
+        },
+        onClick: () => {
+          // Focus should always remain on the list container itself.
+          this.listRef.current.focus();
+        },
+        onBlur: e => {
+          if (active != null) {
+            const { relatedTarget } = e;
+            if (!this.listRef.current.contains(relatedTarget)) {
+              this.setState({ active: null });
+            }
+          }
+        },
+        "aria-label": this.props.label,
+        "aria-labelledby": this.props.labelledBy,
+        "aria-activedescendant": (current != null) ? items[current].key : null,
+      },
+        items.map((item, index) => {
+          return ListItem({
+            item,
+            current: index === current,
+            active: index === active,
+            // We make a key unique depending on whether the list item is in active or
+            // inactive state to make sure that it is actually replaced and the tabbable
+            // state is reset.
+            key: `${item.key}-${index === active ? "active" : "inactive"}`,
+            // Since the user just clicked the item, there's no need to check if it should
+            // be scrolled into view.
+            onClick: () => this._setCurrentItem(index, { preventAutoScroll: true }),
+          });
+        })
+      )
+    );
+  }
+}
+
+module.exports = {
+  ListItem: ListItemClass,
+  List,
+};
--- a/devtools/client/shared/components/moz.build
+++ b/devtools/client/shared/components/moz.build
@@ -14,16 +14,18 @@ DIRS += [
 ]
 
 DevToolsModules(
     'Accordion.css',
     'Accordion.js',
     'AutoCompletePopup.js',
     'Frame.js',
     'HSplitBox.js',
+    'List.css',
+    'List.js',
     'MdnLink.css',
     'MdnLink.js',
     'NotificationBox.css',
     'NotificationBox.js',
     'SearchBox.js',
     'Sidebar.js',
     'SidebarToggle.css',
     'SidebarToggle.js',
--- a/devtools/client/shared/components/test/mochitest/chrome.ini
+++ b/devtools/client/shared/components/test/mochitest/chrome.ini
@@ -2,16 +2,18 @@
 support-files =
   head.js
   accordion.snapshots.js
 
 [test_accordion.html]
 [test_frame_01.html]
 [test_frame_02.html]
 [test_HSplitBox_01.html]
+[test_list.html]
+[test_list_keyboard.html]
 [test_notification_box_01.html]
 [test_notification_box_02.html]
 [test_notification_box_03.html]
 [test_searchbox.html]
 [test_searchbox-with-autocomplete.html]
 [test_sidebar_toggle.html]
 [test_smart-trace-grouping.html]
 [test_smart-trace-source-maps.html]
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/components/test/mochitest/test_list.html
@@ -0,0 +1,127 @@
+<!-- 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 List renders correctly.
+-->
+<head>
+  <meta charset="utf-8">
+  <title>List component test</title>
+  <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.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 src="list.snapshots.js" type="application/javascript"></script>
+<script type="application/javascript">
+
+"use strict";
+
+window.onload = async function() {
+  try {
+    const { div } = require("devtools/client/shared/vendor/react-dom-factories");
+    const React = browserRequire("devtools/client/shared/vendor/react");
+    const {
+      Simulate,
+      renderIntoDocument,
+      findRenderedDOMComponentWithClass,
+      scryRenderedDOMComponentsWithTag,
+      scryRenderedComponentsWithType,
+    } = browserRequire("devtools/client/shared/vendor/react-dom-test-utils");
+    const { List, ListItem } =
+      browserRequire("devtools/client/shared/components/List");
+
+    const testItems = [
+      {
+        component: div({ className: "item-1" }, "Test List Item 1"),
+        className: "list-item-1",
+        key: "list-item-1",
+      },
+      {
+        component: div({ className: "item-2" }, "Test List Item 2"),
+        className: "list-item-2",
+        key: "list-item-2",
+      },
+      {
+        component: div({ className: "item-3" }, "Test List Item 3"),
+        className: "list-item-3",
+        key: "list-item-3",
+      },
+    ];
+
+    const listReactEl = React.createElement(List, {
+      items: testItems,
+      labelledBy: "test-labelledby",
+    });
+
+    const list = renderIntoDocument(listReactEl);
+    const listEl = findRenderedDOMComponentWithClass(list, "list");
+    const items = scryRenderedComponentsWithType(list, ListItem);
+    let itemEls = scryRenderedDOMComponentsWithTag(list, "li");
+
+    function testCurrent(index) {
+      is(list.state.current, index, "Correct current item.");
+      is(listEl.getAttribute("aria-activedescendant"), testItems[index].key,
+         "Correct active descendant.");
+    }
+
+    is(items.length, 3, "Correct number of list item components in tree.");
+    is(itemEls.length, 3, "Correct number of list items is rendered.");
+    info("Testing initial tree properties.");
+    for (let index = 0; index < items.length; index++) {
+      const item = items[index];
+      const itemEl = itemEls[index];
+      const { active, current, item: itemProp } = item.props;
+      const content = itemEl.querySelector(".list-item-content");
+
+      is(active, false, "Correct active state.");
+      is(current, false, "Correct current state.");
+      is(itemProp, testItems[index], "Correct rendered item.");
+      is(item.contentRef.current, content, "Correct content ref.");
+
+      is(itemEl.className, testItems[index].className, "Correct list item class.");
+      is(itemEl.id, testItems[index].key, "Correct list item it.");
+      is(content.getAttribute("role"), "presentation", "Correct content role.");
+
+      is(content.innerHTML,
+         `<div class="item-${index + 1}">Test List Item ${index + 1}</div>`,
+         "Content rendered correctly.");
+    }
+
+    is(list.state.current, null, "Current item is not set by default.");
+    is(list.state.active, null, "Active item is not set by default.");
+    is(list.listRef.current, listEl, "Correct list ref.");
+
+    is(listEl.className, "list", "Correct list class.");
+    is(listEl.tabIndex, 0, "List is focusable.");
+    ok(!listEl.hasAttribute("aria-label"), "List has no label.");
+    is(listEl.getAttribute("aria-labelledby"), "test-labelledby",
+       "Correct list labelled by attribute.");
+    ok(!listEl.hasAttribute("aria-activedescendant"),
+      "No active descendant set by default.");
+
+    Simulate.focus(listEl);
+    testCurrent(0);
+
+    Simulate.click(itemEls[2]);
+    testCurrent(2);
+
+    Simulate.blur(listEl);
+    testCurrent(2);
+
+    Simulate.focus(listEl);
+    testCurrent(2);
+  } 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/components/test/mochitest/test_list_keyboard.html
@@ -0,0 +1,283 @@
+<!-- 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 List component has working keyboard interactions.
+-->
+<head>
+  <meta charset="utf-8">
+  <title>List 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,
+      scryRenderedDOMComponentsWithTag,
+    } = browserRequire("devtools/client/shared/vendor/react-dom-test-utils");
+    const { List } =
+      browserRequire("devtools/client/shared/components/List");
+
+    const testItems = [
+      {
+        component: div({}, "Test List Item 1"),
+        className: "list-item-1",
+        key: "list-item-1",
+      },
+      {
+        component: div({},
+          "Test List Item 2",
+          a({ href: "#" }, "Focusable 1"),
+          button({ }, "Focusable 2")),
+        className: "list-item-2",
+        key: "list-item-2",
+      },
+      {
+        component: div({}, "Test List Item 3"),
+        className: "list-item-3",
+        key: "list-item-3",
+      },
+    ];
+
+    const list = React.createElement(List, {
+      items: testItems,
+      labelledby: "test-labelledby",
+    });
+
+    const tree = ReactDOM.render(list, document.body);
+    const listEl = findRenderedDOMComponentWithClass(tree, "list");
+    const items = scryRenderedDOMComponentsWithTag(tree, "li");
+    const defaultFocus = listEl.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 List state. Keyboard focus is set to document body by default.",
+      state: { current: null, active: null },
+      activeElement: defaultFocus,
+    }, {
+      name: "Current item must be set to the first list item on initial focus. " +
+            "Keyboard focus should be set on list's conatiner (<ul>).",
+      action: () => focusEl(listEl),
+      activeElement: listEl,
+      state: { current: 0 },
+    }, {
+      name: "Current item should remain set even when the list is blured. " +
+            "Keyboard focus should be set back to document body.",
+      action: () => blurEl(listEl),
+      state: { current: 0 },
+      activeElement: defaultFocus,
+    }, {
+      name: "Unset list's current state.",
+      action: () => tree.setState({ current: null }),
+      state: { current: null },
+    }, {
+      name: "Current item must be re-set again to the first list item on initial " +
+            "focus. Keyboard focus should be set on list's conatiner (<ul>).",
+      action: () => focusEl(listEl),
+      activeElement: listEl,
+      state: { current: 0 },
+    }, {
+      name: "Current item should be updated to next on ArrowDown.",
+      event: { type: "keyDown", el: listEl, options: { key: "ArrowDown" }},
+      state: { current: 1 },
+    }, {
+      name: "Current item should be updated to last on ArrowDown.",
+      event: { type: "keyDown", el: listEl, options: { key: "ArrowDown" }},
+      state: { current: 2 },
+    }, {
+      name: "Current item should remain on last on ArrowDown.",
+      event: { type: "keyDown", el: listEl, options: { key: "ArrowDown" }},
+      state: { current: 2 },
+    }, {
+      name: "Current item should be updated to previous on ArrowUp.",
+      event: { type: "keyDown", el: listEl, options: { key: "ArrowUp" }},
+      state: { current: 1 },
+    }, {
+      name: "Current item should be updated to first on ArrowUp.",
+      event: { type: "keyDown", el: listEl, options: { key: "ArrowUp" }},
+      state: { current: 0 },
+    }, {
+      name: "Current item should remain on first on ArrowUp.",
+      event: { type: "keyDown", el: listEl, options: { key: "ArrowUp" }},
+      state: { current: 0 },
+    }, {
+      name: "Current item should be updated to last on End.",
+      event: { type: "keyDown", el: listEl, options: { key: "End" }},
+      state: { current: 2 },
+    }, {
+      name: "Current item should be updated to first on Home.",
+      event: { type: "keyDown", el: listEl, options: { key: "Home" }},
+      state: { current: 0 },
+    }, {
+      name: "Current item should be set as active on Enter.",
+      event: { type: "keyDown", el: listEl, options: { key: "Enter" }},
+      state: { current: 0, active: 0 },
+      activeElement: listEl,
+    }, {
+      name: "Active item should be unset on Escape.",
+      event: { type: "keyDown", el: listEl, options: { key: "Escape" }},
+      state: { current: 0, active: null },
+    }, {
+      name: "Current item should be set as active on Space.",
+      event: { type: "keyDown", el: listEl, options: { key: " " }},
+      state: { current: 0, active: 0 },
+      activeElement: listEl,
+    }, {
+      name: "Current item should unset when focus leaves the list.",
+      action: () => blurEl(listEl),
+      state: { current: 0, active: null },
+      activeElement: defaultFocus,
+    }, {
+      name: "Keyboard focus should be set on list's conatiner (<ul>) on focus.",
+      action: () => focusEl(listEl),
+      activeElement: listEl,
+    }, {
+      name: "Current item should be updated to next on ArrowDown.",
+      event: { type: "keyDown", el: listEl, options: { key: "ArrowDown" }},
+      state: { current: 1, active: null },
+    }, {
+      name: "Current item should be set as active on Enter. Keyboard focus should be " +
+            "set on the first focusable element inside the list item, if available.",
+      event: { type: "keyDown", el: listEl, options: { key: "Enter" }},
+      state: { current: 1, active: 1 },
+      get activeElement() {
+        // When list item 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 " +
+            "list item on Tab.",
+      action() {
+        synthesizeKey("KEY_Tab");
+      },
+      state: { current: 1, active: 1 },
+      get activeElement() {
+        // When list item becomes active/inactive, it is replaced with a newly rendered
+        // one.
+        return findRenderedDOMComponentWithTag(tree, "button");
+      },
+    }, {
+      name: "Keyboard focus should wrap inside the list item when focused on last " +
+            "tabbable element.",
+      action() {
+        synthesizeKey("KEY_Tab");
+      },
+      state: { current: 1, active: 1 },
+      get activeElement() {
+        return findRenderedDOMComponentWithTag(tree, "a");
+      },
+    }, {
+      name: "Keyboard focus should wrap inside the list item when focused on first " +
+            "tabbable element.",
+      action() {
+        synthesizeKey("KEY_Tab", { shiftKey: true });
+      },
+      state: { current: 1, active: 1 },
+      get activeElement() {
+        return findRenderedDOMComponentWithTag(tree, "button");
+      },
+    }, {
+      name: "Active item should be unset on Escape. Focus should move back to the " +
+            "list container.",
+      event: { type: "keyDown", el: listEl, options: { key: "Escape" }},
+      state: { current: 1, active: null },
+      activeElement: listEl,
+    }, {
+      name: "Current item should be set as active on Space. Keyboard focus should be " +
+            "set on the first focusable element inside the list item, if available.",
+      event: { type: "keyDown", el: listEl, options: { key: " " }},
+      state: { current: 1, active: 1 },
+      get activeElement() {
+        // When list item becomes active/inactive, it is replaced with a newly rendered
+        // one.
+        return findRenderedDOMComponentWithTag(tree, "a");
+      },
+    }, {
+      name: "Current item should remain set even when the list is blured. " +
+            "Keyboard focus should be set back to document body.",
+      action: () => listEl.ownerDocument.activeElement.blur(),
+      state: { current: 1, active: null, },
+      activeElement: defaultFocus,
+    }, {
+      name: "Keyboard focus should be set on list's conatiner (<ul>) on focus.",
+      action: () => focusEl(listEl),
+      state: { current: 1, active: null },
+      activeElement: listEl,
+    }, {
+      name: "Current item should be updated to previous on ArrowUp.",
+      event: { type: "keyDown", el: listEl, options: { key: "ArrowUp" }},
+      state: { current: 0, active: null },
+    }, {
+      name: "Current item should be set as active on Enter.",
+      event: { type: "keyDown", el: listEl, options: { key: "Enter" }},
+      state: { current: 0, active: 0 },
+      activeElement: listEl,
+    }, {
+      name: "Keyboard focus should move to another focusable element outside of the " +
+            "list when there's nothing to focus on inside the list item.",
+      action() {
+        synthesizeKey("KEY_Tab", { shiftKey: true });
+      },
+      state: { current: 0, active: null },
+      activeElement: listEl.ownerDocument.documentElement,
+    }];
+
+    for (const test of tests) {
+      const { action, condition, event, state, name } = test;
+
+      is(listEl, findRenderedDOMComponentWithClass(tree, "list"), "Sanity check");
+
+      info(name);
+      if (event) {
+        const { type, options, el } = event;
+        Simulate[type](el, options);
+      } else if (action) {
+        action();
+      }
+
+      if (test.activeElement) {
+        is(listEl.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>