Source code

Revision control

Copy as Markdown

Other Tools

/* 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,
});
}
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;
});