summaryrefslogtreecommitdiffstats
path: root/devtools/client/shared/components/tree
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/shared/components/tree')
-rw-r--r--devtools/client/shared/components/tree/LabelCell.js76
-rw-r--r--devtools/client/shared/components/tree/ObjectProvider.js86
-rw-r--r--devtools/client/shared/components/tree/TreeCell.js139
-rw-r--r--devtools/client/shared/components/tree/TreeHeader.js120
-rw-r--r--devtools/client/shared/components/tree/TreeRow.js304
-rw-r--r--devtools/client/shared/components/tree/TreeView.css199
-rw-r--r--devtools/client/shared/components/tree/TreeView.js799
-rw-r--r--devtools/client/shared/components/tree/moz.build13
8 files changed, 1736 insertions, 0 deletions
diff --git a/devtools/client/shared/components/tree/LabelCell.js b/devtools/client/shared/components/tree/LabelCell.js
new file mode 100644
index 0000000000..e42a9dfd1c
--- /dev/null
+++ b/devtools/client/shared/components/tree/LabelCell.js
@@ -0,0 +1,76 @@
+/* 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";
+
+// Make this available to both AMD and CJS environments
+define(function (require, exports, module) {
+ const { Component } = require("devtools/client/shared/vendor/react");
+ const dom = require("devtools/client/shared/vendor/react-dom-factories");
+ const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
+
+ /**
+ * Render the default cell used for toggle buttons
+ */
+ class LabelCell extends Component {
+ // See the TreeView component for details related
+ // to the 'member' object.
+ static get propTypes() {
+ return {
+ id: PropTypes.string.isRequired,
+ title: PropTypes.string,
+ member: PropTypes.object.isRequired,
+ renderSuffix: PropTypes.func,
+ };
+ }
+
+ render() {
+ const id = this.props.id;
+ const title = this.props.title;
+ const member = this.props.member;
+ const level = member.level || 0;
+ const renderSuffix = this.props.renderSuffix;
+
+ const iconClassList = ["treeIcon"];
+ if (member.hasChildren && member.loading) {
+ iconClassList.push("devtools-throbber");
+ } else if (member.hasChildren) {
+ iconClassList.push("theme-twisty");
+ }
+ if (member.open) {
+ iconClassList.push("open");
+ }
+
+ return dom.td(
+ {
+ className: "treeLabelCell",
+ title,
+ style: {
+ // Compute indentation dynamically. The deeper the item is
+ // inside the hierarchy, the bigger is the left padding.
+ "--tree-label-cell-indent": `${level * 16}px`,
+ },
+ key: "default",
+ role: "presentation",
+ },
+ dom.span({
+ className: iconClassList.join(" "),
+ role: "presentation",
+ }),
+ dom.span(
+ {
+ className: "treeLabel " + member.type + "Label",
+ title,
+ "aria-labelledby": id,
+ "data-level": level,
+ },
+ member.name
+ ),
+ renderSuffix && renderSuffix(member)
+ );
+ }
+ }
+
+ // Exports from this module
+ module.exports = LabelCell;
+});
diff --git a/devtools/client/shared/components/tree/ObjectProvider.js b/devtools/client/shared/components/tree/ObjectProvider.js
new file mode 100644
index 0000000000..48d577ff4d
--- /dev/null
+++ b/devtools/client/shared/components/tree/ObjectProvider.js
@@ -0,0 +1,86 @@
+/* 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";
+
+// Make this available to both AMD and CJS environments
+define(function (require, exports, module) {
+ /**
+ * Implementation of the default data provider. A provider is state less
+ * object responsible for transformation data (usually a state) to
+ * a structure that can be directly consumed by the tree-view component.
+ */
+ const ObjectProvider = {
+ getChildren(object) {
+ const children = [];
+
+ if (object instanceof ObjectProperty) {
+ object = object.value;
+ }
+
+ if (!object) {
+ return [];
+ }
+
+ if (typeof object == "string") {
+ return [];
+ }
+
+ for (const prop in object) {
+ try {
+ children.push(new ObjectProperty(prop, object[prop]));
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ return children;
+ },
+
+ hasChildren(object) {
+ if (object instanceof ObjectProperty) {
+ object = object.value;
+ }
+
+ if (!object) {
+ return false;
+ }
+
+ if (typeof object == "string") {
+ return false;
+ }
+
+ if (typeof object !== "object") {
+ return false;
+ }
+
+ return !!Object.keys(object).length;
+ },
+
+ getLabel(object) {
+ return object instanceof ObjectProperty ? object.name : null;
+ },
+
+ getValue(object) {
+ return object instanceof ObjectProperty ? object.value : null;
+ },
+
+ getKey(object) {
+ return object instanceof ObjectProperty ? object.name : null;
+ },
+
+ getType(object) {
+ return object instanceof ObjectProperty
+ ? typeof object.value
+ : typeof object;
+ },
+ };
+
+ function ObjectProperty(name, value) {
+ this.name = name;
+ this.value = value;
+ }
+
+ // Exports from this module
+ exports.ObjectProperty = ObjectProperty;
+ exports.ObjectProvider = ObjectProvider;
+});
diff --git a/devtools/client/shared/components/tree/TreeCell.js b/devtools/client/shared/components/tree/TreeCell.js
new file mode 100644
index 0000000000..2fec6ad29d
--- /dev/null
+++ b/devtools/client/shared/components/tree/TreeCell.js
@@ -0,0 +1,139 @@
+/* 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";
+
+// Make this available to both AMD and CJS environments
+define(function (require, exports, module) {
+ const { Component } = require("devtools/client/shared/vendor/react");
+ const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
+ const dom = require("devtools/client/shared/vendor/react-dom-factories");
+ const { input, span, td } = dom;
+
+ /**
+ * This template represents a cell in TreeView row. It's rendered
+ * using <td> element (the row is <tr> and the entire tree is <table>).
+ */
+ class TreeCell extends Component {
+ // See TreeView component for detailed property explanation.
+ static get propTypes() {
+ return {
+ value: PropTypes.any,
+ decorator: PropTypes.object,
+ id: PropTypes.string.isRequired,
+ member: PropTypes.object.isRequired,
+ renderValue: PropTypes.func.isRequired,
+ enableInput: PropTypes.bool,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ inputEnabled: false,
+ };
+
+ this.getCellClass = this.getCellClass.bind(this);
+ this.updateInputEnabled = this.updateInputEnabled.bind(this);
+ }
+
+ /**
+ * Optimize cell rendering. Rerender cell content only if
+ * the value or expanded state changes.
+ */
+ shouldComponentUpdate(nextProps, nextState) {
+ return (
+ this.props.value != nextProps.value ||
+ this.state !== nextState ||
+ this.props.member.open != nextProps.member.open
+ );
+ }
+
+ getCellClass(object, id) {
+ const decorator = this.props.decorator;
+ if (!decorator || !decorator.getCellClass) {
+ return [];
+ }
+
+ // Decorator can return a simple string or array of strings.
+ let classNames = decorator.getCellClass(object, id);
+ if (!classNames) {
+ return [];
+ }
+
+ if (typeof classNames == "string") {
+ classNames = [classNames];
+ }
+
+ return classNames;
+ }
+
+ updateInputEnabled(evt) {
+ this.setState(
+ Object.assign({}, this.state, {
+ inputEnabled: evt.target.nodeName.toLowerCase() !== "input",
+ })
+ );
+ }
+
+ render() {
+ let { member, id, value, decorator, renderValue, enableInput } =
+ this.props;
+ const type = member.type || "";
+
+ // Compute class name list for the <td> element.
+ const classNames = this.getCellClass(member.object, id) || [];
+ classNames.push("treeValueCell");
+ classNames.push(type + "Cell");
+
+ // Render value using a default render function or custom
+ // provided function from props or a decorator.
+ renderValue = renderValue || defaultRenderValue;
+ if (decorator?.renderValue) {
+ renderValue = decorator.renderValue(member.object, id) || renderValue;
+ }
+
+ const props = Object.assign({}, this.props, {
+ object: value,
+ });
+
+ let cellElement;
+ if (enableInput && this.state.inputEnabled && type !== "object") {
+ classNames.push("inputEnabled");
+ cellElement = input({
+ autoFocus: true,
+ onBlur: this.updateInputEnabled,
+ readOnly: true,
+ value,
+ "aria-labelledby": id,
+ });
+ } else {
+ cellElement = span(
+ {
+ onClick: type !== "object" ? this.updateInputEnabled : null,
+ "aria-labelledby": id,
+ },
+ renderValue(props)
+ );
+ }
+
+ // Render me!
+ return td(
+ {
+ className: classNames.join(" "),
+ role: "presentation",
+ },
+ cellElement
+ );
+ }
+ }
+
+ // Default value rendering.
+ const defaultRenderValue = props => {
+ return props.object + "";
+ };
+
+ // Exports from this module
+ module.exports = TreeCell;
+});
diff --git a/devtools/client/shared/components/tree/TreeHeader.js b/devtools/client/shared/components/tree/TreeHeader.js
new file mode 100644
index 0000000000..41986210fc
--- /dev/null
+++ b/devtools/client/shared/components/tree/TreeHeader.js
@@ -0,0 +1,120 @@
+/* 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";
+
+// Make this available to both AMD and CJS environments
+define(function (require, exports, module) {
+ const { Component } = require("devtools/client/shared/vendor/react");
+ const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
+ const dom = require("devtools/client/shared/vendor/react-dom-factories");
+ const { thead, tr, td, div } = dom;
+
+ /**
+ * This component is responsible for rendering tree header.
+ * It's based on <thead> element.
+ */
+ class TreeHeader extends Component {
+ // See also TreeView component for detailed info about properties.
+ static get propTypes() {
+ return {
+ // Custom tree decorator
+ decorator: PropTypes.object,
+ // True if the header should be visible
+ header: PropTypes.bool,
+ // Array with column definition
+ columns: PropTypes.array,
+ };
+ }
+
+ static get defaultProps() {
+ return {
+ columns: [
+ {
+ id: "default",
+ },
+ ],
+ };
+ }
+
+ constructor(props) {
+ super(props);
+ this.getHeaderClass = this.getHeaderClass.bind(this);
+ }
+
+ getHeaderClass(colId) {
+ const decorator = this.props.decorator;
+ if (!decorator || !decorator.getHeaderClass) {
+ return [];
+ }
+
+ // Decorator can return a simple string or array of strings.
+ let classNames = decorator.getHeaderClass(colId);
+ if (!classNames) {
+ return [];
+ }
+
+ if (typeof classNames == "string") {
+ classNames = [classNames];
+ }
+
+ return classNames;
+ }
+
+ render() {
+ const cells = [];
+ const visible = this.props.header;
+
+ // Render the rest of the columns (if any)
+ this.props.columns.forEach(col => {
+ const cellStyle = {
+ width: col.width ? col.width : "",
+ };
+
+ let classNames = [];
+
+ if (visible) {
+ classNames = this.getHeaderClass(col.id);
+ classNames.push("treeHeaderCell");
+ }
+
+ cells.push(
+ td(
+ {
+ className: classNames.join(" "),
+ style: cellStyle,
+ role: "presentation",
+ id: col.id,
+ key: col.id,
+ },
+ visible
+ ? div(
+ {
+ className: "treeHeaderCellBox",
+ role: "presentation",
+ },
+ col.title
+ )
+ : null
+ )
+ );
+ });
+
+ return thead(
+ {
+ role: "presentation",
+ },
+ tr(
+ {
+ className: visible ? "treeHeaderRow" : "",
+ role: "presentation",
+ },
+ cells
+ )
+ );
+ }
+ }
+
+ // Exports from this module
+ module.exports = TreeHeader;
+});
diff --git a/devtools/client/shared/components/tree/TreeRow.js b/devtools/client/shared/components/tree/TreeRow.js
new file mode 100644
index 0000000000..7288d3bdfa
--- /dev/null
+++ b/devtools/client/shared/components/tree/TreeRow.js
@@ -0,0 +1,304 @@
+/* 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";
+
+// Make this available to both AMD and CJS environments
+define(function (require, exports, module) {
+ const {
+ Component,
+ createFactory,
+ createRef,
+ } = require("devtools/client/shared/vendor/react");
+ const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
+ const dom = require("devtools/client/shared/vendor/react-dom-factories");
+ const { findDOMNode } = require("devtools/client/shared/vendor/react-dom");
+ const { tr } = dom;
+
+ // Tree
+ const TreeCell = createFactory(
+ require("devtools/client/shared/components/tree/TreeCell")
+ );
+ const LabelCell = createFactory(
+ require("devtools/client/shared/components/tree/LabelCell")
+ );
+
+ const {
+ wrapMoveFocus,
+ getFocusableElements,
+ } = require("devtools/client/shared/focus");
+
+ const UPDATE_ON_PROPS = [
+ "name",
+ "open",
+ "value",
+ "loading",
+ "level",
+ "selected",
+ "active",
+ "hasChildren",
+ ];
+
+ /**
+ * This template represents a node in TreeView component. It's rendered
+ * using <tr> element (the entire tree is one big <table>).
+ */
+ class TreeRow extends Component {
+ // See TreeView component for more details about the props and
+ // the 'member' object.
+ static get propTypes() {
+ return {
+ member: PropTypes.shape({
+ object: PropTypes.object,
+ name: PropTypes.string,
+ type: PropTypes.string.isRequired,
+ rowClass: PropTypes.string.isRequired,
+ level: PropTypes.number.isRequired,
+ hasChildren: PropTypes.bool,
+ value: PropTypes.any,
+ open: PropTypes.bool.isRequired,
+ path: PropTypes.string.isRequired,
+ hidden: PropTypes.bool,
+ selected: PropTypes.bool,
+ active: PropTypes.bool,
+ loading: PropTypes.bool,
+ }),
+ decorator: PropTypes.object,
+ renderCell: PropTypes.func,
+ renderLabelCell: PropTypes.func,
+ columns: PropTypes.array.isRequired,
+ id: PropTypes.string.isRequired,
+ provider: PropTypes.object.isRequired,
+ onClick: PropTypes.func.isRequired,
+ onContextMenu: PropTypes.func,
+ onMouseOver: PropTypes.func,
+ onMouseOut: PropTypes.func,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.treeRowRef = createRef();
+
+ this.getRowClass = this.getRowClass.bind(this);
+ this._onKeyDown = this._onKeyDown.bind(this);
+ }
+
+ componentDidMount() {
+ this._setTabbableState();
+
+ // Child components might add/remove new focusable elements, watch for the
+ // additions/removals of descendant nodes and update focusable state.
+ const win = this.treeRowRef.current.ownerDocument.defaultView;
+ const { MutationObserver } = win;
+ this.observer = new MutationObserver(() => {
+ this._setTabbableState();
+ });
+ this.observer.observe(this.treeRowRef.current, {
+ childList: true,
+ subtree: true,
+ });
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ // I don't like accessing the underlying DOM elements directly,
+ // but this optimization makes the filtering so damn fast!
+ // The row doesn't have to be re-rendered, all we really need
+ // to do is toggling a class name.
+ // The important part is that DOM elements don't need to be
+ // re-created when they should appear again.
+ if (nextProps.member.hidden != this.props.member.hidden) {
+ const row = findDOMNode(this);
+ row.classList.toggle("hidden");
+ }
+ }
+
+ /**
+ * Optimize row rendering. If props are the same do not render.
+ * This makes the rendering a lot faster!
+ */
+ shouldComponentUpdate(nextProps) {
+ for (const prop of UPDATE_ON_PROPS) {
+ if (nextProps.member[prop] != this.props.member[prop]) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ componentWillUnmount() {
+ this.observer.disconnect();
+ this.observer = null;
+ }
+
+ /**
+ * Makes sure that none of the focusable elements inside the row container
+ * are tabbable if the row is not active. If the row is active and focus
+ * is outside its container, focus on the first focusable element inside.
+ */
+ _setTabbableState() {
+ const elms = getFocusableElements(this.treeRowRef.current);
+ if (elms.length === 0) {
+ return;
+ }
+
+ const { active } = this.props.member;
+ if (!active) {
+ elms.forEach(elm => elm.setAttribute("tabindex", "-1"));
+ return;
+ }
+
+ if (!elms.includes(document.activeElement)) {
+ elms[0].focus();
+ }
+ }
+
+ _onKeyDown(e) {
+ const { target, key, shiftKey } = e;
+
+ if (key !== "Tab") {
+ return;
+ }
+
+ const focusMoved = !!wrapMoveFocus(
+ getFocusableElements(this.treeRowRef.current),
+ 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();
+ }
+
+ getRowClass(object) {
+ const decorator = this.props.decorator;
+ if (!decorator || !decorator.getRowClass) {
+ return [];
+ }
+
+ // Decorator can return a simple string or array of strings.
+ let classNames = decorator.getRowClass(object);
+ if (!classNames) {
+ return [];
+ }
+
+ if (typeof classNames == "string") {
+ classNames = [classNames];
+ }
+
+ return classNames;
+ }
+
+ render() {
+ const member = this.props.member;
+ const decorator = this.props.decorator;
+
+ const props = {
+ id: this.props.id,
+ ref: this.treeRowRef,
+ role: "treeitem",
+ "aria-level": member.level + 1,
+ "aria-selected": !!member.selected,
+ onClick: this.props.onClick,
+ onContextMenu: this.props.onContextMenu,
+ onKeyDownCapture: member.active ? this._onKeyDown : undefined,
+ onMouseOver: this.props.onMouseOver,
+ onMouseOut: this.props.onMouseOut,
+ };
+
+ // Compute class name list for the <tr> element.
+ const classNames = this.getRowClass(member.object) || [];
+ classNames.push("treeRow");
+ classNames.push(member.type + "Row");
+
+ if (member.hasChildren) {
+ classNames.push("hasChildren");
+
+ // There are 2 situations where hasChildren is true:
+ // 1. it is an object with children. Only set aria-expanded in this situation
+ // 2. It is a long string (> 50 chars) that can be expanded to fully display it
+ if (member.type !== "string") {
+ props["aria-expanded"] = member.open;
+ }
+ }
+
+ if (member.open) {
+ classNames.push("opened");
+ }
+
+ if (member.loading) {
+ classNames.push("loading");
+ }
+
+ if (member.selected) {
+ classNames.push("selected");
+ }
+
+ if (member.hidden) {
+ classNames.push("hidden");
+ }
+
+ props.className = classNames.join(" ");
+
+ // The label column (with toggle buttons) is usually
+ // the first one, but there might be cases (like in
+ // the Memory panel) where the toggling is done
+ // in the last column.
+ const cells = [];
+
+ // Get components for rendering cells.
+ let renderCell = this.props.renderCell || RenderCell;
+ let renderLabelCell = this.props.renderLabelCell || RenderLabelCell;
+ if (decorator?.renderLabelCell) {
+ renderLabelCell =
+ decorator.renderLabelCell(member.object) || renderLabelCell;
+ }
+
+ // Render a cell for every column.
+ this.props.columns.forEach(col => {
+ const cellProps = Object.assign({}, this.props, {
+ key: col.id,
+ id: col.id,
+ value: this.props.provider.getValue(member.object, col.id),
+ });
+
+ if (decorator?.renderCell) {
+ renderCell = decorator.renderCell(member.object, col.id);
+ }
+
+ const render = col.id == "default" ? renderLabelCell : renderCell;
+
+ // Some cells don't have to be rendered. This happens when some
+ // other cells span more columns. Note that the label cells contains
+ // toggle buttons and should be usually there unless we are rendering
+ // a simple non-expandable table.
+ if (render) {
+ cells.push(render(cellProps));
+ }
+ });
+
+ // Render tree row
+ return tr(props, cells);
+ }
+ }
+
+ // Helpers
+
+ const RenderCell = props => {
+ return TreeCell(props);
+ };
+
+ const RenderLabelCell = props => {
+ return LabelCell(props);
+ };
+
+ // Exports from this module
+ module.exports = TreeRow;
+});
diff --git a/devtools/client/shared/components/tree/TreeView.css b/devtools/client/shared/components/tree/TreeView.css
new file mode 100644
index 0000000000..e244ff3da9
--- /dev/null
+++ b/devtools/client/shared/components/tree/TreeView.css
@@ -0,0 +1,199 @@
+/* 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/. */
+
+@import url('chrome://devtools/content/shared/components/reps/reps.css');
+
+/******************************************************************************/
+/* TreeView Colors */
+
+:root {
+ --tree-header-background: #C8D2DC;
+ --tree-header-sorted-background: #AAC3DC;
+}
+
+/******************************************************************************/
+/* TreeView Table*/
+
+.treeTable {
+ color: var(--theme-highlight-blue);
+}
+
+.treeTable .treeLabelCell,
+.treeTable .treeValueCell {
+ padding: 2px 0;
+ padding-inline-start: 4px;
+ line-height: 16px; /* make rows 20px tall */
+ vertical-align: top;
+ overflow: hidden;
+}
+
+.treeTable .treeLabelCell {
+ white-space: nowrap;
+ cursor: default;
+ padding-inline-start: var(--tree-label-cell-indent);
+}
+
+.treeTable .treeLabelCell::after {
+ content: ":";
+ color: var(--object-color);
+}
+
+.treeTable .treeValueCell.inputEnabled {
+ padding-block: 0;
+}
+
+.treeTable .treeValueCell.inputEnabled input {
+ width: 100%;
+ height: 20px;
+ margin: 0;
+ margin-inline-start: -2px;
+ border: solid 1px transparent;
+ outline: none;
+ box-shadow: none;
+ padding: 0 1px;
+ color: var(--theme-text-color-strong);
+ background: var(--theme-sidebar-background);
+}
+
+.treeTable .treeValueCell.inputEnabled input:focus {
+ border-color: var(--theme-textbox-box-shadow);
+ transition: all 150ms ease-in-out;
+}
+
+.treeTable .treeValueCell > [aria-labelledby],
+.treeTable .treeLabelCell > .treeLabel {
+ unicode-bidi: plaintext;
+ text-align: match-parent;
+}
+
+/* No padding if there is actually no label */
+.treeTable .treeLabel:empty {
+ padding-inline-start: 0;
+}
+
+.treeTable .treeRow.hasChildren > .treeLabelCell > .treeLabel:hover {
+ cursor: pointer;
+ text-decoration: underline;
+}
+
+/* :not(.selected) is used because row selection styles should have
+ more precedence than row hovering. */
+.treeTable .treeRow:not(.selected):hover {
+ background-color: var(--theme-selection-background-hover) !important;
+}
+
+.treeTable .treeRow.selected {
+ background-color: var(--theme-selection-background);
+}
+
+.treeTable .treeRow.selected :where(:not(.objectBox-jsonml)),
+.treeTable .treeRow.selected .treeLabelCell::after {
+ color: var(--theme-selection-color);
+ fill: currentColor;
+}
+
+/* Invert text selection color in selected rows */
+.treeTable .treeRow.selected :not(input, textarea)::selection {
+ color: var(--theme-selection-background);
+ background-color: var(--theme-selection-color);
+}
+
+/* Filtering */
+.treeTable .treeRow.hidden {
+ display: none !important;
+}
+
+.treeTable .treeValueCellDivider {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+}
+
+/* Learn More link */
+.treeTable .treeValueCell .learn-more-link {
+ user-select: none;
+ color: var(--theme-highlight-blue);
+ cursor: pointer;
+ margin: 0 5px;
+}
+
+.treeTable .treeValueCell .learn-more-link:hover {
+ text-decoration: underline;
+}
+
+/******************************************************************************/
+/* Toggle Icon */
+
+.treeTable .treeRow .treeIcon {
+ box-sizing: content-box;
+ height: 14px;
+ width: 14px;
+ padding: 1px;
+ /* Set the size of loading spinner (see .devtools-throbber) */
+ font-size: 10px;
+ line-height: 14px;
+ display: inline-block;
+ vertical-align: bottom;
+ /* Use a total width of 20px (margins + padding + width) */
+ margin-inline: 3px 1px;
+}
+
+/* All expanded/collapsed styles need to apply on immediate children
+ since there might be nested trees within a tree. */
+.treeTable .treeRow.hasChildren > .treeLabelCell > .treeIcon {
+ cursor: pointer;
+ background-repeat: no-repeat;
+}
+
+/******************************************************************************/
+/* Header */
+
+.treeTable .treeHeaderRow {
+ height: 18px;
+}
+
+.treeTable .treeHeaderCell {
+ cursor: pointer;
+ user-select: none;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.2);
+ padding: 0 !important;
+ background: linear-gradient(
+ rgba(255, 255, 255, 0.05),
+ rgba(0, 0, 0, 0.05)),
+ radial-gradient(1px 60% at right,
+ rgba(0, 0, 0, 0.8) 0%,
+ transparent 80%) repeat-x var(--tree-header-background);
+ color: var(--theme-body-color);
+ white-space: nowrap;
+}
+
+.treeTable .treeHeaderCellBox {
+ padding-block: 2px;
+ padding-inline: 10px 14px;
+}
+
+.treeTable .treeHeaderRow > .treeHeaderCell:first-child > .treeHeaderCellBox {
+ padding: 0;
+}
+
+.treeTable .treeHeaderSorted {
+ background-color: var(--tree-header-sorted-background);
+}
+
+.treeTable .treeHeaderSorted > .treeHeaderCellBox {
+ background: url(chrome://devtools/skin/images/sort-descending-arrow.svg) no-repeat calc(100% - 4px);
+}
+
+.treeTable .treeHeaderSorted.sortedAscending > .treeHeaderCellBox {
+ background-image: url(chrome://devtools/skin/images/sort-ascending-arrow.svg);
+}
+
+.treeTable .treeHeaderCell:hover:active {
+ background-image: linear-gradient(
+ rgba(0, 0, 0, 0.1),
+ transparent),
+ radial-gradient(1px 60% at right,
+ rgba(0, 0, 0, 0.8) 0%,
+ transparent 80%);
+}
diff --git a/devtools/client/shared/components/tree/TreeView.js b/devtools/client/shared/components/tree/TreeView.js
new file mode 100644
index 0000000000..d9ef7c0088
--- /dev/null
+++ b/devtools/client/shared/components/tree/TreeView.js
@@ -0,0 +1,799 @@
+/* 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";
+
+// Make this available to both AMD and CJS environments
+define(function (require, exports, module) {
+ const {
+ cloneElement,
+ Component,
+ createFactory,
+ createRef,
+ } = require("devtools/client/shared/vendor/react");
+ const { findDOMNode } = require("devtools/client/shared/vendor/react-dom");
+ const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
+ const dom = require("devtools/client/shared/vendor/react-dom-factories");
+
+ // Reps
+ const {
+ ObjectProvider,
+ } = require("devtools/client/shared/components/tree/ObjectProvider");
+ const TreeRow = createFactory(
+ require("devtools/client/shared/components/tree/TreeRow")
+ );
+ const TreeHeader = createFactory(
+ require("devtools/client/shared/components/tree/TreeHeader")
+ );
+
+ const { scrollIntoView } = require("devtools/client/shared/scroll");
+
+ const SUPPORTED_KEYS = [
+ "ArrowUp",
+ "ArrowDown",
+ "ArrowLeft",
+ "ArrowRight",
+ "End",
+ "Home",
+ "Enter",
+ " ",
+ "Escape",
+ ];
+
+ const defaultProps = {
+ object: null,
+ renderRow: null,
+ provider: ObjectProvider,
+ expandedNodes: new Set(),
+ selected: null,
+ defaultSelectFirstNode: true,
+ active: null,
+ expandableStrings: true,
+ 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,
+ // Array of columns
+ columns: PropTypes.arrayOf(
+ PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ title: PropTypes.string,
+ width: PropTypes.string,
+ })
+ ),
+ };
+ }
+
+ static get defaultProps() {
+ return defaultProps;
+ }
+
+ 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 } = 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)) {
+ 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) {
+ return typeof value == "string" && value.length > 50;
+ }
+
+ // Exports from this module
+ module.exports = TreeView;
+});
diff --git a/devtools/client/shared/components/tree/moz.build b/devtools/client/shared/components/tree/moz.build
new file mode 100644
index 0000000000..0700575f17
--- /dev/null
+++ b/devtools/client/shared/components/tree/moz.build
@@ -0,0 +1,13 @@
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+ "LabelCell.js",
+ "ObjectProvider.js",
+ "TreeCell.js",
+ "TreeHeader.js",
+ "TreeRow.js",
+ "TreeView.js",
+)