summaryrefslogtreecommitdiffstats
path: root/devtools/client/shared/components/VirtualizedTree.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/shared/components/VirtualizedTree.js')
-rw-r--r--devtools/client/shared/components/VirtualizedTree.js1071
1 files changed, 1071 insertions, 0 deletions
diff --git a/devtools/client/shared/components/VirtualizedTree.js b/devtools/client/shared/components/VirtualizedTree.js
new file mode 100644
index 0000000000..4f8dab1bd5
--- /dev/null
+++ b/devtools/client/shared/components/VirtualizedTree.js
@@ -0,0 +1,1071 @@
+/* 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/. */
+/* eslint-env browser */
+"use strict";
+
+const {
+ Component,
+ createFactory,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const {
+ scrollIntoView,
+} = require("resource://devtools/client/shared/scroll.js");
+const {
+ preventDefaultAndStopPropagation,
+} = require("resource://devtools/client/shared/events.js");
+
+loader.lazyRequireGetter(
+ this,
+ ["wrapMoveFocus", "getFocusableElements"],
+ "resource://devtools/client/shared/focus.js",
+ 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
+ * renders the subset of those items which are visible in the viewport. It's
+ * been battle tested on huge trees in the memory panel. We've optimized tree
+ * traversal and rendering, even in the presence of cross-compartment wrappers.
+ *
+ * This tree component doesn't make any assumptions about the structure of your
+ * tree data. Whether children are computed on demand, or stored in an array in
+ * the parent's `_children` property, it doesn't matter. We only require the
+ * implementation of `getChildren`, `getRoots`, `getParent`, and `isExpanded`
+ * functions.
+ *
+ * This tree component is well tested and reliable. See
+ * devtools/client/shared/components/test/mochitest/test_tree_* and its usage in
+ * the performance and memory panels.
+ *
+ * This tree component doesn't make any assumptions about how to render items in
+ * the tree. You provide a `renderItem` function, and this component will ensure
+ * that only those items whose parents are expanded and which are visible in the
+ * viewport are rendered. The `renderItem` function could render the items as a
+ * "traditional" tree or as rows in a table or anything else. It doesn't
+ * restrict you to only one certain kind of tree.
+ *
+ * The only requirement is that every item in the tree render as the same
+ * height. This is required in order to compute which items are visible in the
+ * viewport in constant time.
+ *
+ * ### Example Usage
+ *
+ * Suppose we have some tree data where each item has this form:
+ *
+ * {
+ * id: Number,
+ * label: String,
+ * parent: Item or null,
+ * children: Array of child items,
+ * expanded: bool,
+ * }
+ *
+ * Here is how we could render that data with this component:
+ *
+ * class MyTree extends Component {
+ * static get propTypes() {
+ * // The root item of the tree, with the form described above.
+ * return {
+ * root: PropTypes.object.isRequired
+ * };
+ * }
+ *
+ * render() {
+ * return Tree({
+ * itemHeight: 20, // px
+ *
+ * getRoots: () => [this.props.root],
+ *
+ * getParent: item => item.parent,
+ * getChildren: item => item.children,
+ * getKey: item => item.id,
+ * isExpanded: item => item.expanded,
+ *
+ * renderItem: (item, depth, isFocused, arrow, isExpanded) => {
+ * let className = "my-tree-item";
+ * if (isFocused) {
+ * className += " focused";
+ * }
+ * return dom.div(
+ * {
+ * className,
+ * // Apply 10px nesting per expansion depth.
+ * style: { marginLeft: depth * 10 + "px" }
+ * },
+ * // Here is the expando arrow so users can toggle expansion and
+ * // collapse state.
+ * arrow,
+ * // And here is the label for this item.
+ * dom.span({ className: "my-tree-item-label" }, item.label)
+ * );
+ * },
+ *
+ * onExpand: item => dispatchExpandActionToRedux(item),
+ * onCollapse: item => dispatchCollapseActionToRedux(item),
+ * });
+ * }
+ * }
+ */
+class Tree extends Component {
+ static get propTypes() {
+ return {
+ // Required props
+
+ // A function to get an item's parent, or null if it is a root.
+ //
+ // Type: getParent(item: Item) -> Maybe<Item>
+ //
+ // Example:
+ //
+ // // The parent of this item is stored in its `parent` property.
+ // getParent: item => item.parent
+ getParent: PropTypes.func.isRequired,
+
+ // A function to get an item's children.
+ //
+ // Type: getChildren(item: Item) -> [Item]
+ //
+ // Example:
+ //
+ // // This item's children are stored in its `children` property.
+ // getChildren: item => item.children
+ getChildren: PropTypes.func.isRequired,
+
+ // A function which takes an item and ArrowExpander component instance and
+ // returns a component, or text, or anything else that React considers
+ // renderable.
+ //
+ // Type: renderItem(item: Item,
+ // depth: Number,
+ // isFocused: Boolean,
+ // arrow: ReactComponent,
+ // isExpanded: Boolean) -> ReactRenderable
+ //
+ // Example:
+ //
+ // renderItem: (item, depth, isFocused, arrow, isExpanded) => {
+ // let className = "my-tree-item";
+ // if (isFocused) {
+ // className += " focused";
+ // }
+ // return dom.div(
+ // {
+ // className,
+ // style: { marginLeft: depth * 10 + "px" }
+ // },
+ // arrow,
+ // dom.span({ className: "my-tree-item-label" }, item.label)
+ // );
+ // },
+ renderItem: PropTypes.func.isRequired,
+
+ // A function which returns the roots of the tree (forest).
+ //
+ // Type: getRoots() -> [Item]
+ //
+ // Example:
+ //
+ // // In this case, we only have one top level, root item. You could
+ // // return multiple items if you have many top level items in your
+ // // tree.
+ // getRoots: () => [this.props.rootOfMyTree]
+ getRoots: PropTypes.func.isRequired,
+
+ // A function to get a unique key for the given item. This helps speed up
+ // React's rendering a *TON*.
+ //
+ // Type: getKey(item: Item) -> String
+ //
+ // Example:
+ //
+ // getKey: item => `my-tree-item-${item.uniqueId}`
+ getKey: PropTypes.func.isRequired,
+
+ // A function to get whether an item is expanded or not. If an item is not
+ // expanded, then it must be collapsed.
+ //
+ // Type: isExpanded(item: Item) -> Boolean
+ //
+ // Example:
+ //
+ // isExpanded: item => item.expanded,
+ isExpanded: PropTypes.func.isRequired,
+
+ // The height of an item in the tree including margin and padding, in
+ // pixels.
+ itemHeight: PropTypes.number.isRequired,
+
+ // 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,
+
+ // The currently shown item, if any such item exists.
+ shown: PropTypes.any,
+
+ // 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.
+ autoExpandDepth: PropTypes.number,
+
+ // 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 tree.
+ labelledby: PropTypes.string,
+ // Accessibility label for a tree widget.
+ label: PropTypes.string,
+
+ // Optional event handlers for when items are expanded or collapsed. Useful
+ // for dispatching redux events and updating application state, maybe lazily
+ // loading subtrees from a worker, etc.
+ //
+ // Type:
+ // onExpand(item: Item)
+ // onCollapse(item: Item)
+ //
+ // Example:
+ //
+ // onExpand: item => dispatchExpandActionToRedux(item)
+ onExpand: PropTypes.func,
+ onCollapse: PropTypes.func,
+ };
+ }
+
+ static get defaultProps() {
+ return {
+ autoExpandDepth: AUTO_EXPAND_DEPTH,
+ preventNavigationOnArrowRight: true,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ scroll: 0,
+ height: window.innerHeight,
+ seen: new Set(),
+ mouseDown: false,
+ };
+
+ 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._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._activate = this._activate.bind(this);
+ this._onKeyDown = this._onKeyDown.bind(this);
+ }
+
+ componentDidMount() {
+ window.addEventListener("resize", this._onResize);
+ this._autoExpand();
+ this._updateHeight();
+ this._scrollItemIntoView();
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ this._autoExpand();
+ this._updateHeight();
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ const { scroll, height, seen, mouseDown } = this.state;
+
+ return (
+ scroll !== nextState.scroll ||
+ height !== nextState.height ||
+ seen !== nextState.seen ||
+ mouseDown === nextState.mouseDown
+ );
+ }
+
+ componentDidUpdate() {
+ this._scrollItemIntoView();
+ }
+
+ componentWillUnmount() {
+ window.removeEventListener("resize", this._onResize);
+ }
+
+ _scrollItemIntoView() {
+ const { shown } = this.props;
+ if (!shown) {
+ return;
+ }
+
+ this._scrollIntoView(shown);
+ }
+
+ _autoExpand() {
+ if (!this.props.autoExpandDepth) {
+ return;
+ }
+
+ // Automatically expand the first autoExpandDepth levels for new items. Do
+ // not use the usual DFS infrastructure because we don't want to ignore
+ // collapsed nodes.
+ const autoExpand = (item, currentDepth) => {
+ if (
+ currentDepth >= this.props.autoExpandDepth ||
+ this.state.seen.has(item)
+ ) {
+ return;
+ }
+
+ this.props.onExpand(item);
+ this.state.seen.add(item);
+
+ const children = this.props.getChildren(item);
+ const length = children.length;
+ for (let i = 0; i < length; i++) {
+ autoExpand(children[i], currentDepth + 1);
+ }
+ };
+
+ const roots = this.props.getRoots();
+ const length = roots.length;
+ for (let i = 0; i < length; i++) {
+ autoExpand(roots[i], 0);
+ }
+ }
+
+ _preventArrowKeyScrolling(e) {
+ switch (e.key) {
+ case "ArrowUp":
+ case "ArrowDown":
+ case "ArrowLeft":
+ case "ArrowRight":
+ preventDefaultAndStopPropagation(e);
+ break;
+ }
+ }
+
+ /**
+ * Updates the state's height based on clientHeight.
+ */
+ _updateHeight() {
+ this.setState({ height: this.refs.tree.clientHeight });
+ }
+
+ /**
+ * Perform a pre-order depth-first search from item.
+ */
+ _dfs(item, maxDepth = Infinity, traversal = [], _depth = 0) {
+ traversal.push({ item, depth: _depth });
+
+ if (!this.props.isExpanded(item)) {
+ return traversal;
+ }
+
+ const nextDepth = _depth + 1;
+
+ if (nextDepth > maxDepth) {
+ return traversal;
+ }
+
+ const children = this.props.getChildren(item);
+ const length = children.length;
+ for (let i = 0; i < length; i++) {
+ this._dfs(children[i], maxDepth, traversal, nextDepth);
+ }
+
+ return traversal;
+ }
+
+ /**
+ * Perform a pre-order depth-first search over the whole forest.
+ */
+ _dfsFromRoots(maxDepth = Infinity) {
+ const traversal = [];
+
+ const roots = this.props.getRoots();
+ const length = roots.length;
+ for (let i = 0; i < length; i++) {
+ this._dfs(roots[i], maxDepth, traversal);
+ }
+
+ return traversal;
+ }
+
+ /**
+ * Expands current row.
+ *
+ * @param {Object} item
+ * @param {Boolean} expandAllChildren
+ */
+ _onExpand(item, expandAllChildren) {
+ if (this.props.onExpand) {
+ this.props.onExpand(item);
+
+ if (expandAllChildren) {
+ const children = this._dfs(item);
+ const length = children.length;
+ for (let i = 0; i < length; i++) {
+ this.props.onExpand(children[i].item);
+ }
+ }
+ }
+ }
+
+ /**
+ * Collapses current row.
+ *
+ * @param {Object} item
+ */
+ _onCollapse(item) {
+ if (this.props.onCollapse) {
+ this.props.onCollapse(item);
+ }
+ }
+
+ /**
+ * Scroll item into view. Depending on whether the item is already rendered,
+ * we might have to calculate the position of the item based on its index and
+ * the item height.
+ *
+ * @param {Object} item
+ * The item to be scrolled into view.
+ * @param {Number|undefined} index
+ * The index of the item in a full DFS traversal (ignoring collapsed
+ * nodes) or undefined.
+ * @param {Object} options
+ * Optional information regarding item's requested alignement when
+ * scrolling.
+ */
+ _scrollIntoView(item, index, options = {}) {
+ const treeElement = this.refs.tree;
+ if (!treeElement) {
+ return;
+ }
+
+ const element = document.getElementById(this.props.getKey(item));
+ if (element) {
+ scrollIntoView(element, { ...options, container: treeElement });
+ return;
+ }
+
+ if (index == null) {
+ // If index is not provided, determine item index from traversal.
+ const traversal = this._dfsFromRoots();
+ index = traversal.findIndex(({ item: i }) => i === item);
+ }
+
+ if (index == null || index < 0) {
+ return;
+ }
+
+ const { itemHeight } = this.props;
+ const { clientHeight, scrollTop } = treeElement;
+ const elementTop = index * itemHeight;
+ let scrollTo;
+ if (scrollTop >= elementTop + itemHeight) {
+ scrollTo = elementTop;
+ } else if (scrollTop + clientHeight <= elementTop) {
+ scrollTo = elementTop + itemHeight - clientHeight;
+ }
+
+ if (scrollTo != undefined) {
+ treeElement.scrollTo({
+ left: 0,
+ top: scrollTo,
+ });
+ }
+ }
+
+ /**
+ * Sets the passed in item to be the focused item.
+ *
+ * @param {Number} index
+ * The index of the item in a full DFS traversal (ignoring collapsed
+ * nodes). Ignored if `item` is undefined.
+ *
+ * @param {Object|undefined} item
+ * The item to be focused, or undefined to focus no item.
+ */
+ _focus(index, item, options = {}) {
+ if (item !== undefined && !options.preventAutoScroll) {
+ this._scrollIntoView(item, index, options);
+ }
+
+ 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();
+ }
+
+ /**
+ * 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),
+ height: this.refs.tree.clientHeight,
+ });
+ }
+
+ /**
+ * Handles key down events in the tree's container.
+ *
+ * @param {Event} e
+ */
+ // eslint-disable-next-line complexity
+ _onKeyDown(e) {
+ if (this.props.focused == null) {
+ return;
+ }
+
+ // Allow parent nodes to use navigation arrows with modifiers.
+ if (e.altKey || e.ctrlKey || e.shiftKey || e.metaKey) {
+ return;
+ }
+
+ this._preventArrowKeyScrolling(e);
+
+ switch (e.key) {
+ case "ArrowUp":
+ this._focusPrevNode();
+ break;
+
+ case "ArrowDown":
+ this._focusNextNode();
+ break;
+
+ case "ArrowLeft":
+ if (
+ this.props.isExpanded(this.props.focused) &&
+ this.props.getChildren(this.props.focused).length
+ ) {
+ this._onCollapse(this.props.focused);
+ } else {
+ this._focusParentNode();
+ }
+ break;
+
+ case "ArrowRight":
+ if (
+ this.props.getChildren(this.props.focused).length &&
+ !this.props.isExpanded(this.props.focused)
+ ) {
+ this._onExpand(this.props.focused);
+ } else if (!this.props.preventNavigationOnArrowRight) {
+ this._focusNextNode();
+ }
+ break;
+
+ case "Home":
+ this._focusFirstNode();
+ break;
+
+ case "End":
+ this._focusLastNode();
+ break;
+
+ case "Enter":
+ case " ":
+ // 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;
+ }
+ }
+
+ get activeElement() {
+ return this.refs.tree.ownerDocument.activeElement;
+ }
+
+ _focusFirstNode() {
+ const traversal = this._dfsFromRoots();
+ this._focus(0, traversal[0].item, { alignTo: "top" });
+ }
+
+ _focusLastNode() {
+ const traversal = this._dfsFromRoots();
+ const lastIndex = traversal.length - 1;
+ this._focus(lastIndex, traversal[lastIndex].item, { alignTo: "bottom" });
+ }
+
+ /**
+ * Sets the previous node relative to the currently focused item, to focused.
+ */
+ _focusPrevNode() {
+ // Start a depth first search and keep going until we reach the currently
+ // focused node. Focus the previous node in the DFS, if it exists. If it
+ // doesn't exist, we're at the first node already.
+
+ let prev;
+ let prevIndex;
+
+ const traversal = this._dfsFromRoots();
+ const length = traversal.length;
+ for (let i = 0; i < length; i++) {
+ const item = traversal[i].item;
+ if (item === this.props.focused) {
+ break;
+ }
+ prev = item;
+ prevIndex = i;
+ }
+
+ if (prev === undefined) {
+ return;
+ }
+
+ this._focus(prevIndex, prev, { alignTo: "top" });
+ }
+
+ /**
+ * Handles the down arrow key which will focus either the next child
+ * or sibling row.
+ */
+ _focusNextNode() {
+ // Start a depth first search and keep going until we reach the currently
+ // focused node. Focus the next node in the DFS, if it exists. If it
+ // doesn't exist, we're at the last node already.
+
+ const traversal = this._dfsFromRoots();
+ const length = traversal.length;
+ let i = 0;
+
+ while (i < length) {
+ if (traversal[i].item === this.props.focused) {
+ break;
+ }
+ i++;
+ }
+
+ if (i + 1 < traversal.length) {
+ this._focus(i + 1, traversal[i + 1].item, { alignTo: "bottom" });
+ }
+ }
+
+ /**
+ * Handles the left arrow key, going back up to the current rows'
+ * parent row.
+ */
+ _focusParentNode() {
+ const parent = this.props.getParent(this.props.focused);
+ if (!parent) {
+ return;
+ }
+
+ const traversal = this._dfsFromRoots();
+ const length = traversal.length;
+ let parentIndex = 0;
+ for (; parentIndex < length; parentIndex++) {
+ if (traversal[parentIndex].item === parent) {
+ break;
+ }
+ }
+
+ this._focus(parentIndex, parent, { alignTo: "top" });
+ }
+
+ render() {
+ 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, 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 = [
+ dom.div({
+ key: "top-spacer",
+ role: "presentation",
+ style: {
+ padding: 0,
+ margin: 0,
+ height: topSpacerHeight + "px",
+ },
+ }),
+ ];
+
+ 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({
+ // 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 }),
+ })
+ );
+ }
+
+ nodes.push(
+ dom.div({
+ key: "bottom-spacer",
+ role: "presentation",
+ style: {
+ padding: 0,
+ margin: 0,
+ height: bottomSpacerHeight + "px",
+ },
+ })
+ );
+
+ return dom.div(
+ {
+ className: "tree",
+ ref: "tree",
+ role: "tree",
+ tabIndex: "0",
+ onKeyDown: this._onKeyDown,
+ onKeyPress: this._preventArrowKeyScrolling,
+ onKeyUp: this._preventArrowKeyScrolling,
+ onScroll: this._onScroll,
+ onMouseDown: () => this.setState({ mouseDown: true }),
+ onMouseUp: () => this.setState({ mouseDown: false }),
+ onFocus: () => {
+ if (focused || this.state.mouseDown) {
+ 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: {
+ padding: 0,
+ margin: 0,
+ },
+ },
+ nodes
+ );
+ }
+}
+
+/**
+ * An arrow that displays whether its node is expanded (▼) or collapsed
+ * (▶). When its node has no children, it is hidden.
+ */
+class ArrowExpanderClass extends Component {
+ static get propTypes() {
+ return {
+ item: PropTypes.any.isRequired,
+ visible: PropTypes.bool.isRequired,
+ expanded: PropTypes.bool.isRequired,
+ onCollapse: PropTypes.func.isRequired,
+ onExpand: PropTypes.func.isRequired,
+ };
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ 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",
+ "aria-hidden": true,
+ 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";
+ }
+
+ if (!this.props.visible) {
+ attrs.style = {
+ visibility: "hidden",
+ };
+ }
+
+ return dom.div(attrs);
+ }
+}
+
+class TreeNodeClass extends Component {
+ static get propTypes() {
+ return {
+ id: PropTypes.any.isRequired,
+ focused: PropTypes.bool.isRequired,
+ active: PropTypes.bool.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 = getFocusableElements(this.refs.treenode);
+ 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();
+ }
+ }
+
+ _onKeyDown(e) {
+ const { target, key, shiftKey } = e;
+
+ if (key !== "Tab") {
+ return;
+ }
+
+ const focusMoved = !!wrapMoveFocus(
+ getFocusableElements(this.refs.treenode),
+ 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,
+ });
+
+ const classList = ["tree-node", "div"];
+ if (this.props.index % 2) {
+ 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 : undefined,
+ "aria-expanded": ariaExpanded,
+ "data-expanded": this.props.expanded ? "" : undefined,
+ "data-depth": this.props.depth,
+ style: {
+ padding: 0,
+ margin: 0,
+ },
+ },
+
+ this.props.renderItem(
+ this.props.item,
+ this.props.depth,
+ this.props.focused,
+ arrow,
+ this.props.expanded
+ )
+ );
+ }
+}
+
+const ArrowExpander = createFactory(ArrowExpanderClass);
+const TreeNode = createFactory(TreeNodeClass);
+
+/**
+ * Create a function that calls the given function `fn` only once per animation
+ * frame.
+ *
+ * @param {Function} fn
+ * @returns {Function}
+ */
+function oncePerAnimationFrame(fn) {
+ let animationId = null;
+ let argsToPass = null;
+ return function (...args) {
+ argsToPass = args;
+ if (animationId !== null) {
+ return;
+ }
+
+ animationId = requestAnimationFrame(() => {
+ fn.call(this, ...argsToPass);
+ animationId = null;
+ argsToPass = null;
+ });
+ };
+}
+
+module.exports = Tree;