diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 01:47:29 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 01:47:29 +0000 |
commit | 0ebf5bdf043a27fd3dfb7f92e0cb63d88954c44d (patch) | |
tree | a31f07c9bcca9d56ce61e9a1ffd30ef350d513aa /devtools/client/shared/components/VirtualizedTree.js | |
parent | Initial commit. (diff) | |
download | firefox-esr-0ebf5bdf043a27fd3dfb7f92e0cb63d88954c44d.tar.xz firefox-esr-0ebf5bdf043a27fd3dfb7f92e0cb63d88954c44d.zip |
Adding upstream version 115.8.0esr.upstream/115.8.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/shared/components/VirtualizedTree.js')
-rw-r--r-- | devtools/client/shared/components/VirtualizedTree.js | 1071 |
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; |