797 lines
23 KiB
JavaScript
797 lines
23 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/. */
|
|
|
|
/* eslint no-shadow: ["error", { "allow": ["open", "parent"] }] */
|
|
|
|
import React from "resource://devtools/client/shared/vendor/react.mjs";
|
|
import ReactDOM from "resource://devtools/client/shared/vendor/react-dom.mjs";
|
|
import PropTypes from "resource://devtools/client/shared/vendor/react-prop-types.mjs";
|
|
import * as dom from "resource://devtools/client/shared/vendor/react-dom-factories.mjs";
|
|
|
|
import { ObjectProvider } from "resource://devtools/client/shared/components/tree/ObjectProvider.mjs";
|
|
import TreeRowClass from "resource://devtools/client/shared/components/tree/TreeRow.mjs";
|
|
import TreeHeaderClass from "resource://devtools/client/shared/components/tree/TreeHeader.mjs";
|
|
|
|
import { scrollIntoView } from "resource://devtools/client/shared/scroll.mjs";
|
|
|
|
const { cloneElement, Component, createFactory, createRef } = React;
|
|
const { findDOMNode } = ReactDOM;
|
|
|
|
const TreeRow = createFactory(TreeRowClass);
|
|
const TreeHeader = createFactory(TreeHeaderClass);
|
|
|
|
const SUPPORTED_KEYS = [
|
|
"ArrowUp",
|
|
"ArrowDown",
|
|
"ArrowLeft",
|
|
"ArrowRight",
|
|
"End",
|
|
"Home",
|
|
"Enter",
|
|
" ",
|
|
"Escape",
|
|
];
|
|
|
|
// Use a function in order to avoid shared Set/Array between each instance!
|
|
function getDefaultProps() {
|
|
return {
|
|
object: null,
|
|
renderRow: null,
|
|
provider: ObjectProvider,
|
|
expandedNodes: new Set(),
|
|
selected: null,
|
|
defaultSelectFirstNode: true,
|
|
active: null,
|
|
expandableStrings: true,
|
|
maxStringLength: 50,
|
|
columns: [],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* This component represents a tree view with expandable/collapsible nodes.
|
|
* The tree is rendered using <table> element where every node is represented
|
|
* by <tr> element. The tree is one big table where nodes (rows) are properly
|
|
* indented from the left to mimic hierarchical structure of the data.
|
|
*
|
|
* The tree can have arbitrary number of columns and so, might be use
|
|
* as an expandable tree-table UI widget as well. By default, there is
|
|
* one column for node label and one for node value.
|
|
*
|
|
* The tree is maintaining its (presentation) state, which consists
|
|
* from list of expanded nodes and list of columns.
|
|
*
|
|
* Complete data provider interface:
|
|
* var TreeProvider = {
|
|
* getChildren: function(object);
|
|
* hasChildren: function(object);
|
|
* getLabel: function(object, colId);
|
|
* getLevel: function(object); // optional
|
|
* getValue: function(object, colId);
|
|
* getKey: function(object);
|
|
* getType: function(object);
|
|
* }
|
|
*
|
|
* Complete tree decorator interface:
|
|
* var TreeDecorator = {
|
|
* getRowClass: function(object);
|
|
* getCellClass: function(object, colId);
|
|
* getHeaderClass: function(colId);
|
|
* renderValue: function(object, colId);
|
|
* renderRow: function(object);
|
|
* renderCell: function(object, colId);
|
|
* renderLabelCell: function(object);
|
|
* }
|
|
*/
|
|
class TreeView extends Component {
|
|
// The only required property (not set by default) is the input data
|
|
// object that is used to populate the tree.
|
|
static get propTypes() {
|
|
return {
|
|
// The input data object.
|
|
object: PropTypes.any,
|
|
className: PropTypes.string,
|
|
label: PropTypes.string,
|
|
// Data provider (see also the interface above)
|
|
provider: PropTypes.shape({
|
|
getChildren: PropTypes.func,
|
|
hasChildren: PropTypes.func,
|
|
getLabel: PropTypes.func,
|
|
getValue: PropTypes.func,
|
|
getKey: PropTypes.func,
|
|
getLevel: PropTypes.func,
|
|
getType: PropTypes.func,
|
|
}).isRequired,
|
|
// Tree decorator (see also the interface above)
|
|
decorator: PropTypes.shape({
|
|
getRowClass: PropTypes.func,
|
|
getCellClass: PropTypes.func,
|
|
getHeaderClass: PropTypes.func,
|
|
renderValue: PropTypes.func,
|
|
renderRow: PropTypes.func,
|
|
renderCell: PropTypes.func,
|
|
renderLabelCell: PropTypes.func,
|
|
}),
|
|
// Custom tree row (node) renderer
|
|
renderRow: PropTypes.func,
|
|
// Custom cell renderer
|
|
renderCell: PropTypes.func,
|
|
// Custom value renderer
|
|
renderValue: PropTypes.func,
|
|
// Custom tree label (including a toggle button) renderer
|
|
renderLabelCell: PropTypes.func,
|
|
// Set of expanded nodes
|
|
expandedNodes: PropTypes.object,
|
|
// Selected node
|
|
selected: PropTypes.string,
|
|
// Select first node by default
|
|
defaultSelectFirstNode: PropTypes.bool,
|
|
// The currently active (keyboard) item, if any such item exists.
|
|
active: PropTypes.string,
|
|
// Custom filtering callback
|
|
onFilter: PropTypes.func,
|
|
// Custom sorting callback
|
|
onSort: PropTypes.func,
|
|
// Custom row click callback
|
|
onClickRow: PropTypes.func,
|
|
// Row context menu event handler
|
|
onContextMenuRow: PropTypes.func,
|
|
// Tree context menu event handler
|
|
onContextMenuTree: PropTypes.func,
|
|
// A header is displayed if set to true
|
|
header: PropTypes.bool,
|
|
// Long string is expandable by a toggle button
|
|
expandableStrings: PropTypes.bool,
|
|
// Length at which a string is considered long
|
|
maxStringLength: PropTypes.number,
|
|
// Array of columns
|
|
columns: PropTypes.arrayOf(
|
|
PropTypes.shape({
|
|
id: PropTypes.string.isRequired,
|
|
title: PropTypes.string,
|
|
width: PropTypes.string,
|
|
})
|
|
),
|
|
};
|
|
}
|
|
|
|
static get defaultProps() {
|
|
return getDefaultProps();
|
|
}
|
|
|
|
static subPath(path, subKey) {
|
|
return path + "/" + String(subKey).replace(/[\\/]/g, "\\$&");
|
|
}
|
|
|
|
/**
|
|
* Creates a set with the paths of the nodes that should be expanded by default
|
|
* according to the passed options.
|
|
* @param {Object} The root node of the tree.
|
|
* @param {Object} [optional] An object with the following optional parameters:
|
|
* - maxLevel: nodes nested deeper than this level won't be expanded.
|
|
* - maxNodes: maximum number of nodes that can be expanded. The traversal is
|
|
breadth-first, so expanding nodes nearer to the root will be preferred.
|
|
Sibling nodes will either be all expanded or none expanded.
|
|
* }
|
|
*/
|
|
static getExpandedNodes(
|
|
rootObj,
|
|
{ maxLevel = Infinity, maxNodes = Infinity } = {}
|
|
) {
|
|
const expandedNodes = new Set();
|
|
const queue = [
|
|
{
|
|
object: rootObj,
|
|
level: 1,
|
|
path: "",
|
|
},
|
|
];
|
|
while (queue.length) {
|
|
const { object, level, path } = queue.shift();
|
|
if (Object(object) !== object) {
|
|
continue;
|
|
}
|
|
const keys = Object.keys(object);
|
|
if (expandedNodes.size + keys.length > maxNodes) {
|
|
// Avoid having children half expanded.
|
|
break;
|
|
}
|
|
for (const key of keys) {
|
|
const nodePath = TreeView.subPath(path, key);
|
|
expandedNodes.add(nodePath);
|
|
if (level < maxLevel) {
|
|
queue.push({
|
|
object: object[key],
|
|
level: level + 1,
|
|
path: nodePath,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return expandedNodes;
|
|
}
|
|
|
|
constructor(props) {
|
|
super(props);
|
|
|
|
this.state = {
|
|
expandedNodes: props.expandedNodes,
|
|
columns: ensureDefaultColumn(props.columns),
|
|
selected: props.selected,
|
|
active: props.active,
|
|
lastSelectedIndex: props.defaultSelectFirstNode ? 0 : null,
|
|
mouseDown: false,
|
|
};
|
|
|
|
this.treeRef = createRef();
|
|
|
|
this.toggle = this.toggle.bind(this);
|
|
this.isExpanded = this.isExpanded.bind(this);
|
|
this.onFocus = this.onFocus.bind(this);
|
|
this.onKeyDown = this.onKeyDown.bind(this);
|
|
this.onClickRow = this.onClickRow.bind(this);
|
|
this.getSelectedRow = this.getSelectedRow.bind(this);
|
|
this.selectRow = this.selectRow.bind(this);
|
|
this.activateRow = this.activateRow.bind(this);
|
|
this.isSelected = this.isSelected.bind(this);
|
|
this.onFilter = this.onFilter.bind(this);
|
|
this.onSort = this.onSort.bind(this);
|
|
this.getMembers = this.getMembers.bind(this);
|
|
this.renderRows = this.renderRows.bind(this);
|
|
}
|
|
|
|
// FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
|
|
UNSAFE_componentWillReceiveProps(nextProps) {
|
|
const { expandedNodes, selected } = nextProps;
|
|
const state = {
|
|
expandedNodes,
|
|
lastSelectedIndex: this.getSelectedRowIndex(),
|
|
};
|
|
|
|
if (selected) {
|
|
state.selected = selected;
|
|
}
|
|
|
|
this.setState(Object.assign({}, this.state, state));
|
|
}
|
|
|
|
shouldComponentUpdate(nextProps, nextState) {
|
|
const {
|
|
expandedNodes,
|
|
columns,
|
|
selected,
|
|
active,
|
|
lastSelectedIndex,
|
|
mouseDown,
|
|
} = this.state;
|
|
|
|
return (
|
|
expandedNodes !== nextState.expandedNodes ||
|
|
columns !== nextState.columns ||
|
|
selected !== nextState.selected ||
|
|
active !== nextState.active ||
|
|
lastSelectedIndex !== nextState.lastSelectedIndex ||
|
|
mouseDown === nextState.mouseDown
|
|
);
|
|
}
|
|
|
|
componentDidUpdate() {
|
|
const selected = this.getSelectedRow();
|
|
if (selected || this.state.active) {
|
|
return;
|
|
}
|
|
|
|
const rows = this.visibleRows;
|
|
if (rows.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Only select a row if there is a previous lastSelected Index
|
|
// This mostly happens when the treeview is loaded the first time
|
|
if (this.state.lastSelectedIndex !== null) {
|
|
this.selectRow(
|
|
rows[Math.min(this.state.lastSelectedIndex, rows.length - 1)],
|
|
{ alignTo: "top" }
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get rows that are currently visible. Some rows can be filtered and made
|
|
* invisible, in which case, when navigating around the tree we need to
|
|
* ignore the ones that are not reachable by the user.
|
|
*/
|
|
get visibleRows() {
|
|
return this.rows.filter(row => {
|
|
const rowEl = findDOMNode(row);
|
|
return rowEl?.offsetParent;
|
|
});
|
|
}
|
|
|
|
// Node expand/collapse
|
|
|
|
toggle(nodePath) {
|
|
const nodes = this.state.expandedNodes;
|
|
if (this.isExpanded(nodePath)) {
|
|
nodes.delete(nodePath);
|
|
} else {
|
|
nodes.add(nodePath);
|
|
}
|
|
|
|
// Compute new state and update the tree.
|
|
this.setState(
|
|
Object.assign({}, this.state, {
|
|
expandedNodes: nodes,
|
|
})
|
|
);
|
|
}
|
|
|
|
isExpanded(nodePath) {
|
|
return this.state.expandedNodes.has(nodePath);
|
|
}
|
|
|
|
// Event Handlers
|
|
|
|
onFocus(_event) {
|
|
if (this.state.mouseDown) {
|
|
return;
|
|
}
|
|
// Set focus to the first element, if none is selected or activated
|
|
// This is needed because keyboard navigation won't work without an element being selected
|
|
this.componentDidUpdate();
|
|
}
|
|
|
|
// eslint-disable-next-line complexity
|
|
onKeyDown(event) {
|
|
const keyEligibleForFirstLetterNavigation = event.key.length === 1;
|
|
if (
|
|
(!SUPPORTED_KEYS.includes(event.key) &&
|
|
!keyEligibleForFirstLetterNavigation) ||
|
|
event.shiftKey ||
|
|
event.ctrlKey ||
|
|
event.metaKey ||
|
|
event.altKey
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const row = this.getSelectedRow();
|
|
if (!row) {
|
|
return;
|
|
}
|
|
|
|
const rows = this.visibleRows;
|
|
const index = rows.indexOf(row);
|
|
const { hasChildren, open } = row.props.member;
|
|
|
|
switch (event.key) {
|
|
case "ArrowRight":
|
|
if (hasChildren) {
|
|
if (open) {
|
|
const firstChildRow = this.rows
|
|
.slice(index + 1)
|
|
.find(r => r.props.member.level > row.props.member.level);
|
|
if (firstChildRow) {
|
|
this.selectRow(firstChildRow, { alignTo: "bottom" });
|
|
}
|
|
} else {
|
|
this.toggle(this.state.selected);
|
|
}
|
|
}
|
|
break;
|
|
case "ArrowLeft":
|
|
if (hasChildren && open) {
|
|
this.toggle(this.state.selected);
|
|
} else {
|
|
const parentRow = rows
|
|
.slice(0, index)
|
|
.reverse()
|
|
.find(r => r.props.member.level < row.props.member.level);
|
|
if (parentRow) {
|
|
this.selectRow(parentRow, { alignTo: "top" });
|
|
}
|
|
}
|
|
break;
|
|
case "ArrowDown":
|
|
const nextRow = rows[index + 1];
|
|
if (nextRow) {
|
|
this.selectRow(nextRow, { alignTo: "bottom" });
|
|
}
|
|
break;
|
|
case "ArrowUp":
|
|
const previousRow = rows[index - 1];
|
|
if (previousRow) {
|
|
this.selectRow(previousRow, { alignTo: "top" });
|
|
}
|
|
break;
|
|
case "Home":
|
|
const firstRow = rows[0];
|
|
|
|
if (firstRow) {
|
|
this.selectRow(firstRow, { alignTo: "top" });
|
|
}
|
|
break;
|
|
case "End":
|
|
const lastRow = rows[rows.length - 1];
|
|
if (lastRow) {
|
|
this.selectRow(lastRow, { alignTo: "bottom" });
|
|
}
|
|
break;
|
|
case "Enter":
|
|
case " ":
|
|
// On space or enter make selected row active. This means keyboard
|
|
// focus handling is passed on to the tree row itself.
|
|
if (this.treeRef.current === document.activeElement) {
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
if (this.state.active !== this.state.selected) {
|
|
this.activateRow(this.state.selected);
|
|
}
|
|
|
|
return;
|
|
}
|
|
break;
|
|
case "Escape":
|
|
event.stopPropagation();
|
|
if (this.state.active != null) {
|
|
this.activateRow(null);
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (keyEligibleForFirstLetterNavigation) {
|
|
const next = rows
|
|
.slice(index + 1)
|
|
.find(r => r.props.member.name.startsWith(event.key));
|
|
if (next) {
|
|
this.selectRow(next, { alignTo: "bottom" });
|
|
}
|
|
}
|
|
|
|
// Focus should always remain on the tree container itself.
|
|
this.treeRef.current.focus();
|
|
event.preventDefault();
|
|
}
|
|
|
|
onClickRow(nodePath, event) {
|
|
const onClickRow = this.props.onClickRow;
|
|
const row = this.visibleRows.find(r => r.props.member.path === nodePath);
|
|
|
|
// Call custom click handler and bail out if it returns true.
|
|
if (
|
|
onClickRow &&
|
|
onClickRow.call(this, nodePath, event, row.props.member)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
event.stopPropagation();
|
|
|
|
const cell = event.target.closest("td");
|
|
if (cell && cell.classList.contains("treeLabelCell")) {
|
|
this.toggle(nodePath);
|
|
}
|
|
|
|
this.selectRow(row, { preventAutoScroll: true });
|
|
}
|
|
|
|
onContextMenu(member, event) {
|
|
const onContextMenuRow = this.props.onContextMenuRow;
|
|
if (onContextMenuRow) {
|
|
onContextMenuRow.call(this, member, event);
|
|
}
|
|
}
|
|
|
|
getSelectedRow() {
|
|
const rows = this.visibleRows;
|
|
if (!this.state.selected || rows.length === 0) {
|
|
return null;
|
|
}
|
|
return rows.find(row => this.isSelected(row.props.member.path));
|
|
}
|
|
|
|
getSelectedRowIndex() {
|
|
const row = this.getSelectedRow();
|
|
if (!row) {
|
|
return this.props.defaultSelectFirstNode ? 0 : null;
|
|
}
|
|
|
|
return this.visibleRows.indexOf(row);
|
|
}
|
|
|
|
_scrollIntoView(row, options = {}) {
|
|
const treeEl = this.treeRef.current;
|
|
if (!treeEl || !row) {
|
|
return;
|
|
}
|
|
|
|
const { props: { member: { path } = {} } = {} } = row;
|
|
if (!path) {
|
|
return;
|
|
}
|
|
|
|
const element = treeEl.ownerDocument.getElementById(path);
|
|
if (!element) {
|
|
return;
|
|
}
|
|
|
|
scrollIntoView(element, { ...options });
|
|
}
|
|
|
|
selectRow(row, options = {}) {
|
|
const { props: { member: { path } = {} } = {} } = row;
|
|
if (this.isSelected(path)) {
|
|
return;
|
|
}
|
|
|
|
if (this.state.active != null) {
|
|
const treeEl = this.treeRef.current;
|
|
if (treeEl && treeEl !== treeEl.ownerDocument.activeElement) {
|
|
treeEl.focus();
|
|
}
|
|
}
|
|
|
|
if (!options.preventAutoScroll) {
|
|
this._scrollIntoView(row, options);
|
|
}
|
|
|
|
this.setState({
|
|
...this.state,
|
|
selected: path,
|
|
active: null,
|
|
});
|
|
}
|
|
|
|
activateRow(active) {
|
|
this.setState({
|
|
...this.state,
|
|
active,
|
|
});
|
|
}
|
|
|
|
isSelected(nodePath) {
|
|
return nodePath === this.state.selected;
|
|
}
|
|
|
|
isActive(nodePath) {
|
|
return nodePath === this.state.active;
|
|
}
|
|
|
|
// Filtering & Sorting
|
|
|
|
/**
|
|
* Filter out nodes that don't correspond to the current filter.
|
|
* @return {Boolean} true if the node should be visible otherwise false.
|
|
*/
|
|
onFilter(object) {
|
|
const onFilter = this.props.onFilter;
|
|
return onFilter ? onFilter(object) : true;
|
|
}
|
|
|
|
onSort(parent, children) {
|
|
const onSort = this.props.onSort;
|
|
return onSort ? onSort(parent, children) : children;
|
|
}
|
|
|
|
// Members
|
|
|
|
/**
|
|
* Return children node objects (so called 'members') for given
|
|
* parent object.
|
|
*/
|
|
getMembers(parent, level, path) {
|
|
// Strings don't have children. Note that 'long' strings are using
|
|
// the expander icon (+/-) to display the entire original value,
|
|
// but there are no child items.
|
|
if (typeof parent == "string") {
|
|
return [];
|
|
}
|
|
|
|
const { expandableStrings, provider, maxStringLength } = this.props;
|
|
let children = provider.getChildren(parent) || [];
|
|
|
|
// If the return value is non-array, the children
|
|
// are being loaded asynchronously.
|
|
if (!Array.isArray(children)) {
|
|
return children;
|
|
}
|
|
|
|
children = this.onSort(parent, children) || children;
|
|
|
|
return children.map(child => {
|
|
const key = provider.getKey(child);
|
|
const nodePath = TreeView.subPath(path, key);
|
|
const type = provider.getType(child);
|
|
let hasChildren = provider.hasChildren(child);
|
|
|
|
// Value with no column specified is used for optimization.
|
|
// The row is re-rendered only if this value changes.
|
|
// Value for actual column is get when a cell is rendered.
|
|
const value = provider.getValue(child);
|
|
|
|
if (expandableStrings && isLongString(value, maxStringLength)) {
|
|
hasChildren = true;
|
|
}
|
|
|
|
// Return value is a 'member' object containing meta-data about
|
|
// tree node. It describes node label, value, type, etc.
|
|
return {
|
|
// An object associated with this node.
|
|
object: child,
|
|
// A label for the child node
|
|
name: provider.getLabel(child),
|
|
// Data type of the child node (used for CSS customization)
|
|
type,
|
|
// Class attribute computed from the type.
|
|
rowClass: "treeRow-" + type,
|
|
// Level of the child within the hierarchy (top == 0)
|
|
level: provider.getLevel ? provider.getLevel(child, level) : level,
|
|
// True if this node has children.
|
|
hasChildren,
|
|
// Value associated with this node (as provided by the data provider)
|
|
value,
|
|
// True if the node is expanded.
|
|
open: this.isExpanded(nodePath),
|
|
// Node path
|
|
path: nodePath,
|
|
// True if the node is hidden (used for filtering)
|
|
hidden: !this.onFilter(child),
|
|
// True if the node is selected with keyboard
|
|
selected: this.isSelected(nodePath),
|
|
// True if the node is activated with keyboard
|
|
active: this.isActive(nodePath),
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Render tree rows/nodes.
|
|
*/
|
|
renderRows(parent, level = 0, path = "") {
|
|
let rows = [];
|
|
const decorator = this.props.decorator;
|
|
let renderRow = this.props.renderRow || TreeRow;
|
|
|
|
// Get children for given parent node, iterate over them and render
|
|
// a row for every one. Use row template (a component) from properties.
|
|
// If the return value is non-array, the children are being loaded
|
|
// asynchronously.
|
|
const members = this.getMembers(parent, level, path);
|
|
if (!Array.isArray(members)) {
|
|
return members;
|
|
}
|
|
|
|
members.forEach(member => {
|
|
if (decorator?.renderRow) {
|
|
renderRow = decorator.renderRow(member.object) || renderRow;
|
|
}
|
|
|
|
const props = Object.assign({}, this.props, {
|
|
key: `${member.path}-${member.active ? "active" : "inactive"}`,
|
|
member,
|
|
columns: this.state.columns,
|
|
id: member.path,
|
|
ref: row => row && this.rows.push(row),
|
|
onClick: this.onClickRow.bind(this, member.path),
|
|
onContextMenu: this.onContextMenu.bind(this, member),
|
|
});
|
|
|
|
// Render single row.
|
|
rows.push(renderRow(props));
|
|
|
|
// If a child node is expanded render its rows too.
|
|
if (member.hasChildren && member.open) {
|
|
const childRows = this.renderRows(
|
|
member.object,
|
|
level + 1,
|
|
member.path
|
|
);
|
|
|
|
// If children needs to be asynchronously fetched first,
|
|
// set 'loading' property to the parent row. Otherwise
|
|
// just append children rows to the array of all rows.
|
|
if (!Array.isArray(childRows)) {
|
|
const lastIndex = rows.length - 1;
|
|
props.member.loading = true;
|
|
rows[lastIndex] = cloneElement(rows[lastIndex], props);
|
|
} else {
|
|
rows = rows.concat(childRows);
|
|
}
|
|
}
|
|
});
|
|
|
|
return rows;
|
|
}
|
|
|
|
render() {
|
|
const root = this.props.object;
|
|
const classNames = ["treeTable"];
|
|
this.rows = [];
|
|
|
|
const { className, onContextMenuTree } = this.props;
|
|
// Use custom class name from props.
|
|
if (className) {
|
|
classNames.push(...className.split(" "));
|
|
}
|
|
|
|
// Alright, let's render all tree rows. The tree is one big <table>.
|
|
let rows = this.renderRows(root, 0, "");
|
|
|
|
// This happens when the view needs to do initial asynchronous
|
|
// fetch for the root object. The tree might provide a hook API
|
|
// for rendering animated spinner (just like for tree nodes).
|
|
if (!Array.isArray(rows)) {
|
|
rows = [];
|
|
}
|
|
|
|
const props = Object.assign({}, this.props, {
|
|
columns: this.state.columns,
|
|
});
|
|
|
|
return dom.table(
|
|
{
|
|
className: classNames.join(" "),
|
|
role: "tree",
|
|
ref: this.treeRef,
|
|
tabIndex: 0,
|
|
onFocus: this.onFocus,
|
|
onKeyDown: this.onKeyDown,
|
|
onContextMenu: onContextMenuTree && onContextMenuTree.bind(this),
|
|
onMouseDown: () => this.setState({ mouseDown: true }),
|
|
onMouseUp: () => this.setState({ mouseDown: false }),
|
|
onClick: () => {
|
|
// Focus should always remain on the tree container itself.
|
|
this.treeRef.current.focus();
|
|
},
|
|
onBlur: event => {
|
|
if (this.state.active != null) {
|
|
const { relatedTarget } = event;
|
|
if (!this.treeRef.current.contains(relatedTarget)) {
|
|
this.activateRow(null);
|
|
}
|
|
}
|
|
},
|
|
"aria-label": this.props.label || "",
|
|
"aria-activedescendant": this.state.selected,
|
|
cellPadding: 0,
|
|
cellSpacing: 0,
|
|
},
|
|
TreeHeader(props),
|
|
dom.tbody(
|
|
{
|
|
role: "presentation",
|
|
tabIndex: -1,
|
|
},
|
|
rows
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
// Helpers
|
|
|
|
/**
|
|
* There should always be at least one column (the one with toggle buttons)
|
|
* and this function ensures that it's true.
|
|
*/
|
|
function ensureDefaultColumn(columns) {
|
|
if (!columns) {
|
|
columns = [];
|
|
}
|
|
|
|
const defaultColumn = columns.filter(col => col.id == "default");
|
|
if (defaultColumn.length) {
|
|
return columns;
|
|
}
|
|
|
|
// The default column is usually the first one.
|
|
return [{ id: "default" }, ...columns];
|
|
}
|
|
|
|
function isLongString(value, maxStringLength) {
|
|
return typeof value == "string" && value.length > maxStringLength;
|
|
}
|
|
|
|
// Exports from this module
|
|
export default TreeView;
|