diff options
Diffstat (limited to 'devtools/client/shared/components/tree')
-rw-r--r-- | devtools/client/shared/components/tree/LabelCell.js | 76 | ||||
-rw-r--r-- | devtools/client/shared/components/tree/ObjectProvider.js | 86 | ||||
-rw-r--r-- | devtools/client/shared/components/tree/TreeCell.js | 139 | ||||
-rw-r--r-- | devtools/client/shared/components/tree/TreeHeader.js | 120 | ||||
-rw-r--r-- | devtools/client/shared/components/tree/TreeRow.js | 304 | ||||
-rw-r--r-- | devtools/client/shared/components/tree/TreeView.css | 199 | ||||
-rw-r--r-- | devtools/client/shared/components/tree/TreeView.js | 799 | ||||
-rw-r--r-- | devtools/client/shared/components/tree/moz.build | 13 |
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", +) |