1073 lines
30 KiB
JavaScript
1073 lines
30 KiB
JavaScript
/* 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 React = require("resource://devtools/client/shared/vendor/react.mjs");
|
|
const { Component, createFactory } = React;
|
|
const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
|
|
const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.mjs");
|
|
|
|
// Localized strings for (devtools/client/locales/en-US/components.properties)
|
|
loader.lazyGetter(this, "L10N_COMPONENTS", function () {
|
|
const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
|
|
return new LocalizationHelper(
|
|
"devtools/client/locales/components.properties"
|
|
);
|
|
});
|
|
|
|
loader.lazyGetter(this, "EXPAND_LABEL", function () {
|
|
return L10N_COMPONENTS.getStr("treeNode.expandButtonTitle");
|
|
});
|
|
|
|
loader.lazyGetter(this, "COLLAPSE_LABEL", function () {
|
|
return L10N_COMPONENTS.getStr("treeNode.collapseButtonTitle");
|
|
});
|
|
|
|
// depth
|
|
const AUTO_EXPAND_DEPTH = 0;
|
|
|
|
// Simplied selector targetting elements that can receive the focus,
|
|
// full version at https://stackoverflow.com/questions/1599660.
|
|
const FOCUSABLE_SELECTOR = [
|
|
"a[href]:not([tabindex='-1'])",
|
|
"button:not([disabled], [tabindex='-1'])",
|
|
"iframe:not([tabindex='-1'])",
|
|
"input:not([disabled], [tabindex='-1'])",
|
|
"select:not([disabled], [tabindex='-1'])",
|
|
"textarea:not([disabled], [tabindex='-1'])",
|
|
"[tabindex]:not([tabindex='-1'])",
|
|
].join(", ");
|
|
|
|
/**
|
|
* An arrow that displays whether its node is expanded (▼) or collapsed
|
|
* (▶). When its node has no children, it is hidden.
|
|
*/
|
|
class ArrowExpander extends Component {
|
|
static get propTypes() {
|
|
return {
|
|
expanded: PropTypes.bool,
|
|
};
|
|
}
|
|
|
|
shouldComponentUpdate(nextProps) {
|
|
return this.props.expanded !== nextProps.expanded;
|
|
}
|
|
|
|
render() {
|
|
const { expanded } = this.props;
|
|
|
|
const classNames = ["theme-twisty"];
|
|
const title = expanded ? COLLAPSE_LABEL : EXPAND_LABEL;
|
|
|
|
if (expanded) {
|
|
classNames.push("open");
|
|
}
|
|
return dom.button({
|
|
className: classNames.join(" "),
|
|
title,
|
|
});
|
|
}
|
|
}
|
|
|
|
const treeIndent = dom.span({ className: "tree-indent" }, "\u200B");
|
|
const treeLastIndent = dom.span(
|
|
{ className: "tree-indent tree-last-indent" },
|
|
"\u200B"
|
|
);
|
|
|
|
class TreeNode extends Component {
|
|
static get propTypes() {
|
|
return {
|
|
id: PropTypes.any.isRequired,
|
|
index: PropTypes.number.isRequired,
|
|
depth: PropTypes.number.isRequired,
|
|
focused: PropTypes.bool.isRequired,
|
|
active: PropTypes.bool.isRequired,
|
|
expanded: PropTypes.bool.isRequired,
|
|
item: PropTypes.any.isRequired,
|
|
isExpandable: PropTypes.bool.isRequired,
|
|
onClick: PropTypes.func,
|
|
shouldItemUpdate: PropTypes.func,
|
|
renderItem: PropTypes.func.isRequired,
|
|
};
|
|
}
|
|
|
|
constructor(props) {
|
|
super(props);
|
|
|
|
this.treeNodeRef = React.createRef();
|
|
|
|
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 (this.props.active) {
|
|
const doc = this.treeNodeRef.current.ownerDocument;
|
|
if (elms.length && !elms.includes(doc.activeElement)) {
|
|
elms[0].focus();
|
|
}
|
|
} else {
|
|
elms.forEach(elm => elm.setAttribute("tabindex", "-1"));
|
|
}
|
|
}
|
|
|
|
shouldComponentUpdate(nextProps) {
|
|
return (
|
|
this.props.item !== nextProps.item ||
|
|
(this.props.shouldItemUpdate &&
|
|
this.props.shouldItemUpdate(this.props.item, nextProps.item)) ||
|
|
this.props.focused !== nextProps.focused ||
|
|
this.props.expanded !== nextProps.expanded ||
|
|
this.props.depth !== nextProps.depth
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get a list of all elements that are focusable with a keyboard inside the
|
|
* tree node.
|
|
*/
|
|
getFocusableElements() {
|
|
return this.treeNodeRef.current
|
|
? Array.from(
|
|
this.treeNodeRef.current.querySelectorAll(FOCUSABLE_SELECTOR)
|
|
)
|
|
: [];
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
depth,
|
|
id,
|
|
item,
|
|
focused,
|
|
active,
|
|
expanded,
|
|
renderItem,
|
|
isExpandable,
|
|
} = this.props;
|
|
|
|
const arrow = isExpandable
|
|
? ArrowExpanderFactory({
|
|
item,
|
|
expanded,
|
|
})
|
|
: null;
|
|
|
|
let ariaExpanded;
|
|
if (this.props.isExpandable) {
|
|
ariaExpanded = false;
|
|
}
|
|
if (this.props.expanded) {
|
|
ariaExpanded = true;
|
|
}
|
|
|
|
const indents = Array.from({ length: depth }, (_, i) => {
|
|
if (i == depth - 1) {
|
|
return treeLastIndent;
|
|
}
|
|
return treeIndent;
|
|
});
|
|
|
|
const items = indents.concat(
|
|
renderItem(item, depth, focused, arrow, expanded)
|
|
);
|
|
|
|
return dom.div(
|
|
{
|
|
id,
|
|
className: `tree-node${focused ? " focused" : ""}${
|
|
active ? " active" : ""
|
|
}`,
|
|
onClick: this.props.onClick,
|
|
onKeyDownCapture: active ? this._onKeyDown : null,
|
|
role: "treeitem",
|
|
ref: this.treeNodeRef,
|
|
"aria-level": depth + 1,
|
|
"aria-expanded": ariaExpanded,
|
|
"data-expandable": this.props.isExpandable,
|
|
},
|
|
...items
|
|
);
|
|
}
|
|
}
|
|
|
|
const ArrowExpanderFactory = createFactory(ArrowExpander);
|
|
const TreeNodeFactory = createFactory(TreeNode);
|
|
|
|
/**
|
|
* Create a function that calls the given function `fn` only once per animation
|
|
* frame.
|
|
*
|
|
* @param {Function} fn
|
|
* @param {Object} options: object that contains the following properties:
|
|
* - {Function} getDocument: A function that return the document
|
|
* the component is rendered in.
|
|
* @returns {Function}
|
|
*/
|
|
function oncePerAnimationFrame(fn, { getDocument }) {
|
|
let animationId = null;
|
|
let argsToPass = null;
|
|
return function (...args) {
|
|
argsToPass = args;
|
|
if (animationId !== null) {
|
|
return;
|
|
}
|
|
|
|
const doc = getDocument();
|
|
if (!doc) {
|
|
return;
|
|
}
|
|
|
|
animationId = doc.defaultView.requestAnimationFrame(() => {
|
|
fn.call(this, ...argsToPass);
|
|
animationId = null;
|
|
argsToPass = null;
|
|
});
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A generic tree component. See propTypes for the public API.
|
|
*
|
|
* 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 the tests in ./tests
|
|
* and its usage in the memory panel in mozilla-central.
|
|
*
|
|
* 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 tree comes with basic styling for the indent, the arrow, as well as
|
|
* hovered and focused styles which can be override in CSS.
|
|
*
|
|
* ### 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({
|
|
* 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,
|
|
* },
|
|
* 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 to check if the tree node for the item should be updated.
|
|
//
|
|
// Type: shouldItemUpdate(prevItem: Item, nextItem: Item) -> Boolean
|
|
//
|
|
// Example:
|
|
//
|
|
// // This item should be updated if it's type is a long string
|
|
// shouldItemUpdate: (prevItem, nextItem) =>
|
|
// nextItem.type === "longstring"
|
|
shouldItemUpdate: PropTypes.func,
|
|
|
|
// 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,
|
|
|
|
// 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 depth to which we should automatically expand new items.
|
|
autoExpandDepth: PropTypes.number,
|
|
// Should auto expand all new items or just the new items under the first
|
|
// root item.
|
|
autoExpandAll: PropTypes.bool,
|
|
|
|
// Auto expand a node only if number of its children
|
|
// are less than autoExpandNodeChildrenLimit
|
|
autoExpandNodeChildrenLimit: 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,
|
|
// The currently active (keyboard) item, if any such item exists.
|
|
active: PropTypes.any,
|
|
// Optional event handler called with the current focused node when the
|
|
// Enter key is pressed. Can be useful to allow further keyboard actions
|
|
// within the tree node.
|
|
onActivate: PropTypes.func,
|
|
isExpandable: PropTypes.func,
|
|
// Additional classes to add to the root element.
|
|
className: PropTypes.string,
|
|
// style object to be applied to the root element.
|
|
style: PropTypes.object,
|
|
// Prevents blur when Tree loses focus
|
|
preventBlur: PropTypes.bool,
|
|
initiallyExpanded: PropTypes.func,
|
|
};
|
|
}
|
|
|
|
static get defaultProps() {
|
|
return {
|
|
autoExpandDepth: AUTO_EXPAND_DEPTH,
|
|
autoExpandAll: true,
|
|
};
|
|
}
|
|
|
|
constructor(props) {
|
|
super(props);
|
|
|
|
this.state = {
|
|
autoExpanded: new Set(),
|
|
};
|
|
|
|
this.treeRef = React.createRef();
|
|
|
|
const opaf = fn =>
|
|
oncePerAnimationFrame(fn, {
|
|
getDocument: () =>
|
|
this.treeRef.current && this.treeRef.current.ownerDocument,
|
|
});
|
|
|
|
this._onExpand = opaf(this._onExpand).bind(this);
|
|
this._onCollapse = opaf(this._onCollapse).bind(this);
|
|
this._focusPrevNode = opaf(this._focusPrevNode).bind(this);
|
|
this._focusNextNode = opaf(this._focusNextNode).bind(this);
|
|
this._focusParentNode = opaf(this._focusParentNode).bind(this);
|
|
this._focusFirstNode = opaf(this._focusFirstNode).bind(this);
|
|
this._focusLastNode = opaf(this._focusLastNode).bind(this);
|
|
|
|
this._autoExpand = this._autoExpand.bind(this);
|
|
this._preventArrowKeyScrolling = this._preventArrowKeyScrolling.bind(this);
|
|
this._preventEvent = this._preventEvent.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._scrollNodeIntoView = this._scrollNodeIntoView.bind(this);
|
|
this._onBlur = this._onBlur.bind(this);
|
|
this._onKeyDown = this._onKeyDown.bind(this);
|
|
this._nodeIsExpandable = this._nodeIsExpandable.bind(this);
|
|
}
|
|
|
|
componentDidMount() {
|
|
this._autoExpand();
|
|
if (this.props.focused) {
|
|
this._scrollNodeIntoView(this.props.focused);
|
|
}
|
|
}
|
|
|
|
// FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
|
|
UNSAFE_componentWillReceiveProps() {
|
|
this._autoExpand();
|
|
}
|
|
|
|
componentDidUpdate(prevProps) {
|
|
if (this.props.focused && prevProps.focused !== this.props.focused) {
|
|
this._scrollNodeIntoView(this.props.focused);
|
|
}
|
|
}
|
|
|
|
_autoExpand() {
|
|
const { autoExpandDepth, autoExpandNodeChildrenLimit, initiallyExpanded } =
|
|
this.props;
|
|
|
|
if (!autoExpandDepth && !initiallyExpanded) {
|
|
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. Any initially expanded items will be expanded regardless
|
|
// of how deep they are.
|
|
const autoExpand = (item, currentDepth) => {
|
|
const initial = initiallyExpanded && initiallyExpanded(item);
|
|
|
|
if (!initial && currentDepth >= autoExpandDepth) {
|
|
return;
|
|
}
|
|
|
|
const children = this.props.getChildren(item);
|
|
if (
|
|
!initial &&
|
|
autoExpandNodeChildrenLimit &&
|
|
children.length > autoExpandNodeChildrenLimit
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (!this.state.autoExpanded.has(item)) {
|
|
this.props.onExpand(item);
|
|
this.state.autoExpanded.add(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;
|
|
if (this.props.autoExpandAll) {
|
|
for (let i = 0; i < length; i++) {
|
|
autoExpand(roots[i], 0);
|
|
}
|
|
} else if (length != 0) {
|
|
autoExpand(roots[0], 0);
|
|
|
|
if (initiallyExpanded) {
|
|
for (let i = 1; i < length; i++) {
|
|
if (initiallyExpanded(roots[i])) {
|
|
autoExpand(roots[i], 0);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
_preventArrowKeyScrolling(e) {
|
|
switch (e.key) {
|
|
case "ArrowUp":
|
|
case "ArrowDown":
|
|
case "ArrowLeft":
|
|
case "ArrowRight":
|
|
this._preventEvent(e);
|
|
break;
|
|
}
|
|
}
|
|
|
|
_preventEvent(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (e.nativeEvent) {
|
|
if (e.nativeEvent.preventDefault) {
|
|
e.nativeEvent.preventDefault();
|
|
}
|
|
if (e.nativeEvent.stopPropagation) {
|
|
e.nativeEvent.stopPropagation();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets the passed in item to be the focused item.
|
|
*
|
|
* @param {Object|undefined} item
|
|
* The item to be focused, or undefined to focus no item.
|
|
*
|
|
* @param {Object|undefined} options
|
|
* An options object which can contain:
|
|
* - dir: "up" or "down" to indicate if we should scroll the element
|
|
* to the top or the bottom of the scrollable container when
|
|
* the element is off canvas.
|
|
*/
|
|
_focus(item, options = {}) {
|
|
const { preventAutoScroll } = options;
|
|
if (item && !preventAutoScroll) {
|
|
this._scrollNodeIntoView(item, options);
|
|
}
|
|
|
|
if (this.props.active != undefined) {
|
|
this._activate(undefined);
|
|
const doc = this.treeRef.current && this.treeRef.current.ownerDocument;
|
|
if (this.treeRef.current !== doc.activeElement) {
|
|
this.treeRef.current.focus();
|
|
}
|
|
}
|
|
|
|
if (this.props.onFocus) {
|
|
this.props.onFocus(item);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets the passed in item to be the active item.
|
|
*
|
|
* @param {Object|undefined} item
|
|
* The item to be activated, or undefined to activate no item.
|
|
*/
|
|
_activate(item) {
|
|
if (this.props.onActivate) {
|
|
this.props.onActivate(item);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets the passed in item to be the focused item.
|
|
*
|
|
* @param {Object|undefined} item
|
|
* The item to be scrolled to.
|
|
*
|
|
* @param {Object|undefined} options
|
|
* An options object which can contain:
|
|
* - dir: "up" or "down" to indicate if we should scroll the element
|
|
* to the top or the bottom of the scrollable container when
|
|
* the element is off canvas.
|
|
*/
|
|
_scrollNodeIntoView(item, options = {}) {
|
|
if (item !== undefined) {
|
|
const treeElement = this.treeRef.current;
|
|
const doc = treeElement && treeElement.ownerDocument;
|
|
const element = doc.getElementById(this.props.getKey(item));
|
|
|
|
if (element) {
|
|
const { top, bottom } = element.getBoundingClientRect();
|
|
const closestScrolledParent = node => {
|
|
if (node == null) {
|
|
return null;
|
|
}
|
|
|
|
if (node.scrollHeight > node.clientHeight) {
|
|
return node;
|
|
}
|
|
return closestScrolledParent(node.parentNode);
|
|
};
|
|
const scrolledParent = closestScrolledParent(treeElement);
|
|
const scrolledParentRect = scrolledParent
|
|
? scrolledParent.getBoundingClientRect()
|
|
: null;
|
|
const isVisible =
|
|
!scrolledParent ||
|
|
(top >= scrolledParentRect.top &&
|
|
bottom <= scrolledParentRect.bottom);
|
|
|
|
if (!isVisible) {
|
|
const { alignTo } = options;
|
|
const scrollToTop = alignTo
|
|
? alignTo === "top"
|
|
: !scrolledParentRect || top < scrolledParentRect.top;
|
|
element.scrollIntoView(scrollToTop);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets the state to have no focused item.
|
|
*/
|
|
_onBlur(e) {
|
|
if (this.props.active != undefined) {
|
|
const { relatedTarget } = e;
|
|
if (!this.treeRef.current.contains(relatedTarget)) {
|
|
this._activate(undefined);
|
|
}
|
|
} else if (!this.props.preventBlur) {
|
|
this._focus(undefined);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
const doc = this.treeRef.current && this.treeRef.current.ownerDocument;
|
|
|
|
switch (e.key) {
|
|
case "ArrowUp":
|
|
this._focusPrevNode();
|
|
return;
|
|
|
|
case "ArrowDown":
|
|
this._focusNextNode();
|
|
return;
|
|
|
|
case "ArrowLeft":
|
|
if (
|
|
this.props.isExpanded(this.props.focused) &&
|
|
this._nodeIsExpandable(this.props.focused)
|
|
) {
|
|
this._onCollapse(this.props.focused);
|
|
} else {
|
|
this._focusParentNode();
|
|
}
|
|
return;
|
|
|
|
case "ArrowRight":
|
|
if (
|
|
this._nodeIsExpandable(this.props.focused) &&
|
|
!this.props.isExpanded(this.props.focused)
|
|
) {
|
|
this._onExpand(this.props.focused);
|
|
} else {
|
|
this._focusNextNode();
|
|
}
|
|
return;
|
|
|
|
case "Home":
|
|
this._focusFirstNode();
|
|
return;
|
|
|
|
case "End":
|
|
this._focusLastNode();
|
|
return;
|
|
|
|
case "Enter":
|
|
case " ":
|
|
if (this.treeRef.current === doc.activeElement) {
|
|
this._preventEvent(e);
|
|
if (this.props.active !== this.props.focused) {
|
|
this._activate(this.props.focused);
|
|
}
|
|
}
|
|
return;
|
|
|
|
case "Escape":
|
|
this._preventEvent(e);
|
|
if (this.props.active != undefined) {
|
|
this._activate(undefined);
|
|
}
|
|
|
|
if (this.treeRef.current !== doc.activeElement) {
|
|
this.treeRef.current.focus();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
|
|
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;
|
|
}
|
|
if (prev === undefined) {
|
|
return;
|
|
}
|
|
|
|
this._focus(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(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) {
|
|
this._focusPrevNode(this.props.focused);
|
|
return;
|
|
}
|
|
|
|
this._focus(parent, { alignTo: "top" });
|
|
}
|
|
|
|
_focusFirstNode() {
|
|
const traversal = this._dfsFromRoots();
|
|
this._focus(traversal[0].item, { alignTo: "top" });
|
|
}
|
|
|
|
_focusLastNode() {
|
|
const traversal = this._dfsFromRoots();
|
|
const lastIndex = traversal.length - 1;
|
|
this._focus(traversal[lastIndex].item, { alignTo: "bottom" });
|
|
}
|
|
|
|
_nodeIsExpandable(item) {
|
|
return this.props.isExpandable
|
|
? this.props.isExpandable(item)
|
|
: !!this.props.getChildren(item).length;
|
|
}
|
|
|
|
render() {
|
|
const traversal = this._dfsFromRoots();
|
|
const { active, focused } = this.props;
|
|
|
|
const nodes = traversal.map((v, i) => {
|
|
const { item, depth } = traversal[i];
|
|
const key = this.props.getKey(item, i);
|
|
const focusedKey = focused ? this.props.getKey(focused, i) : null;
|
|
return TreeNodeFactory({
|
|
// 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"}`,
|
|
id: key,
|
|
index: i,
|
|
item,
|
|
depth,
|
|
shouldItemUpdate: this.props.shouldItemUpdate,
|
|
renderItem: this.props.renderItem,
|
|
focused: focusedKey === key,
|
|
active: active === item,
|
|
expanded: this.props.isExpanded(item),
|
|
isExpandable: this._nodeIsExpandable(item),
|
|
onExpand: this._onExpand,
|
|
onCollapse: this._onCollapse,
|
|
onClick: e => {
|
|
// We can stop the propagation since click handler on the node can be
|
|
// created in `renderItem`.
|
|
e.stopPropagation();
|
|
|
|
// Since the user just clicked the node, there's no need to check if
|
|
// it should be scrolled into view.
|
|
this._focus(item, { preventAutoScroll: true });
|
|
if (this.props.isExpanded(item)) {
|
|
this.props.onCollapse(item, e.altKey);
|
|
} else {
|
|
this.props.onExpand(item, e.altKey);
|
|
}
|
|
|
|
// Focus should always remain on the tree container itself.
|
|
this.treeRef.current.focus();
|
|
},
|
|
});
|
|
});
|
|
|
|
const style = Object.assign({}, this.props.style || {});
|
|
|
|
return dom.div(
|
|
{
|
|
className: `tree ${this.props.className ? this.props.className : ""}`,
|
|
ref: this.treeRef,
|
|
role: "tree",
|
|
tabIndex: "0",
|
|
onKeyDown: this._onKeyDown,
|
|
onKeyPress: this._preventArrowKeyScrolling,
|
|
onKeyUp: this._preventArrowKeyScrolling,
|
|
onFocus: ({ nativeEvent }) => {
|
|
if (focused || !nativeEvent || !this.treeRef.current) {
|
|
return;
|
|
}
|
|
|
|
const { explicitOriginalTarget } = nativeEvent;
|
|
// Only set default focus to the first tree node if the focus came
|
|
// from outside the tree (e.g. by tabbing to the tree from other
|
|
// external elements).
|
|
if (
|
|
explicitOriginalTarget !== this.treeRef.current &&
|
|
!this.treeRef.current.contains(explicitOriginalTarget)
|
|
) {
|
|
this._focus(traversal[0].item);
|
|
}
|
|
},
|
|
onBlur: this._onBlur,
|
|
"aria-label": this.props.label,
|
|
"aria-labelledby": this.props.labelledby,
|
|
"aria-activedescendant": focused && this.props.getKey(focused),
|
|
style,
|
|
},
|
|
nodes
|
|
);
|
|
}
|
|
}
|
|
|
|
module.exports = Tree;
|