TreeRow.mjs (8627B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 /* eslint no-shadow: ["error", { "allow": ["MutationObserver"] }] */ 6 7 import React from "resource://devtools/client/shared/vendor/react.mjs"; 8 import PropTypes from "resource://devtools/client/shared/vendor/react-prop-types.mjs"; 9 import * as dom from "resource://devtools/client/shared/vendor/react-dom-factories.mjs"; 10 import ReactDOM from "resource://devtools/client/shared/vendor/react-dom.mjs"; 11 12 import TreeCellClass from "resource://devtools/client/shared/components/tree/TreeCell.mjs"; 13 import LabelCellClass from "resource://devtools/client/shared/components/tree/LabelCell.mjs"; 14 15 import { 16 wrapMoveFocus, 17 getFocusableElements, 18 } from "resource://devtools/client/shared/focus.mjs"; 19 20 const { tr } = dom; 21 const { findDOMNode } = ReactDOM; 22 const { Component, createFactory, createRef } = React; 23 24 // Tree 25 const TreeCell = createFactory(TreeCellClass); 26 const LabelCell = createFactory(LabelCellClass); 27 28 const UPDATE_ON_PROPS = [ 29 "name", 30 "open", 31 "value", 32 "loading", 33 "level", 34 "selected", 35 "active", 36 "hasChildren", 37 ]; 38 39 /** 40 * This template represents a node in TreeView component. It's rendered 41 * using <tr> element (the entire tree is one big <table>). 42 */ 43 class TreeRow extends Component { 44 // See TreeView component for more details about the props and 45 // the 'member' object. 46 static get propTypes() { 47 return { 48 member: PropTypes.shape({ 49 object: PropTypes.object, 50 name: PropTypes.string, 51 type: PropTypes.string.isRequired, 52 rowClass: PropTypes.string.isRequired, 53 level: PropTypes.number.isRequired, 54 hasChildren: PropTypes.bool, 55 value: PropTypes.any, 56 open: PropTypes.bool.isRequired, 57 path: PropTypes.string.isRequired, 58 hidden: PropTypes.bool, 59 selected: PropTypes.bool, 60 active: PropTypes.bool, 61 loading: PropTypes.bool, 62 }), 63 decorator: PropTypes.object, 64 renderCell: PropTypes.func, 65 renderLabelCell: PropTypes.func, 66 columns: PropTypes.array.isRequired, 67 id: PropTypes.string.isRequired, 68 provider: PropTypes.object.isRequired, 69 onClick: PropTypes.func.isRequired, 70 onContextMenu: PropTypes.func, 71 onMouseOver: PropTypes.func, 72 onMouseOut: PropTypes.func, 73 }; 74 } 75 76 constructor(props) { 77 super(props); 78 79 this.treeRowRef = createRef(); 80 81 this.getRowClass = this.getRowClass.bind(this); 82 this._onKeyDown = this._onKeyDown.bind(this); 83 } 84 85 componentDidMount() { 86 this._setTabbableState(); 87 88 // Child components might add/remove new focusable elements, watch for the 89 // additions/removals of descendant nodes and update focusable state. 90 const win = this.treeRowRef.current.ownerDocument.defaultView; 91 const { MutationObserver } = win; 92 this.observer = new MutationObserver(() => { 93 this._setTabbableState(); 94 }); 95 this.observer.observe(this.treeRowRef.current, { 96 childList: true, 97 subtree: true, 98 }); 99 } 100 101 // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 102 UNSAFE_componentWillReceiveProps(nextProps) { 103 // I don't like accessing the underlying DOM elements directly, 104 // but this optimization makes the filtering so damn fast! 105 // The row doesn't have to be re-rendered, all we really need 106 // to do is toggling a class name. 107 // The important part is that DOM elements don't need to be 108 // re-created when they should appear again. 109 if (nextProps.member.hidden != this.props.member.hidden) { 110 const row = findDOMNode(this); 111 row.classList.toggle("hidden"); 112 } 113 } 114 115 /** 116 * Optimize row rendering. If props are the same do not render. 117 * This makes the rendering a lot faster! 118 */ 119 shouldComponentUpdate(nextProps) { 120 for (const prop of UPDATE_ON_PROPS) { 121 if (nextProps.member[prop] !== this.props.member[prop]) { 122 return true; 123 } 124 } 125 126 return false; 127 } 128 129 componentWillUnmount() { 130 this.observer.disconnect(); 131 this.observer = null; 132 } 133 134 /** 135 * Makes sure that none of the focusable elements inside the row container 136 * are tabbable if the row is not active. If the row is active and focus 137 * is outside its container, focus on the first focusable element inside. 138 */ 139 _setTabbableState() { 140 const elms = getFocusableElements(this.treeRowRef.current); 141 if (elms.length === 0) { 142 return; 143 } 144 145 const { active } = this.props.member; 146 if (!active) { 147 elms.forEach(elm => elm.setAttribute("tabindex", "-1")); 148 return; 149 } 150 151 if (!elms.includes(document.activeElement)) { 152 elms[0].focus(); 153 } 154 } 155 156 _onKeyDown(e) { 157 const { target, key, shiftKey } = e; 158 159 if (key !== "Tab") { 160 return; 161 } 162 163 const focusMoved = !!wrapMoveFocus( 164 getFocusableElements(this.treeRowRef.current), 165 target, 166 shiftKey 167 ); 168 if (focusMoved) { 169 // Focus was moved to the begining/end of the list, so we need to 170 // prevent the default focus change that would happen here. 171 e.preventDefault(); 172 } 173 174 e.stopPropagation(); 175 } 176 177 getRowClass(object) { 178 const decorator = this.props.decorator; 179 if (!decorator || !decorator.getRowClass) { 180 return []; 181 } 182 183 // Decorator can return a simple string or array of strings. 184 let classNames = decorator.getRowClass(object); 185 if (!classNames) { 186 return []; 187 } 188 189 if (typeof classNames == "string") { 190 classNames = [classNames]; 191 } 192 193 return classNames; 194 } 195 196 render() { 197 const member = this.props.member; 198 const decorator = this.props.decorator; 199 200 const props = { 201 id: this.props.id, 202 ref: this.treeRowRef, 203 role: "treeitem", 204 "aria-level": member.level + 1, 205 "aria-selected": !!member.selected, 206 onClick: this.props.onClick, 207 onContextMenu: this.props.onContextMenu, 208 onKeyDownCapture: member.active ? this._onKeyDown : undefined, 209 onMouseOver: this.props.onMouseOver, 210 onMouseOut: this.props.onMouseOut, 211 }; 212 213 // Compute class name list for the <tr> element. 214 const classNames = this.getRowClass(member.object) || []; 215 classNames.push("treeRow"); 216 classNames.push(member.type + "Row"); 217 218 if (member.hasChildren) { 219 classNames.push("hasChildren"); 220 221 // There are 2 situations where hasChildren is true: 222 // 1. it is an object with children. Only set aria-expanded in this situation 223 // 2. It is a long string (> 50 chars) that can be expanded to fully display it 224 if (member.type !== "string") { 225 props["aria-expanded"] = member.open; 226 } 227 } 228 229 if (member.open) { 230 classNames.push("opened"); 231 } 232 233 if (member.loading) { 234 classNames.push("loading"); 235 } 236 237 if (member.selected) { 238 classNames.push("selected"); 239 } 240 241 if (member.hidden) { 242 classNames.push("hidden"); 243 } 244 245 props.className = classNames.join(" "); 246 247 // The label column (with toggle buttons) is usually 248 // the first one, but there might be cases (like in 249 // the Memory panel) where the toggling is done 250 // in the last column. 251 const cells = []; 252 253 // Get components for rendering cells. 254 let renderCell = this.props.renderCell || RenderCell; 255 let renderLabelCell = this.props.renderLabelCell || RenderLabelCell; 256 if (decorator?.renderLabelCell) { 257 renderLabelCell = 258 decorator.renderLabelCell(member.object) || renderLabelCell; 259 } 260 261 // Render a cell for every column. 262 this.props.columns.forEach(col => { 263 const cellProps = Object.assign({}, this.props, { 264 key: col.id, 265 id: col.id, 266 value: this.props.provider.getValue(member.object, col.id), 267 }); 268 269 if (decorator?.renderCell) { 270 renderCell = decorator.renderCell(member.object, col.id); 271 } 272 273 const render = col.id == "default" ? renderLabelCell : renderCell; 274 275 // Some cells don't have to be rendered. This happens when some 276 // other cells span more columns. Note that the label cells contains 277 // toggle buttons and should be usually there unless we are rendering 278 // a simple non-expandable table. 279 if (render) { 280 cells.push(render(cellProps)); 281 } 282 }); 283 284 // Render tree row 285 return tr(props, cells); 286 } 287 } 288 289 // Helpers 290 291 const RenderCell = props => { 292 return TreeCell(props); 293 }; 294 295 const RenderLabelCell = props => { 296 return LabelCell(props); 297 }; 298 299 // Exports from this module 300 export default TreeRow;