VirtualizedTree.js (30778B)
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 file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 "use strict"; 5 6 const { 7 Component, 8 createFactory, 9 } = require("resource://devtools/client/shared/vendor/react.mjs"); 10 const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.mjs"); 11 const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); 12 const { scrollIntoView } = ChromeUtils.importESModule( 13 "resource://devtools/client/shared/scroll.mjs" 14 ); 15 const { 16 preventDefaultAndStopPropagation, 17 } = require("resource://devtools/client/shared/events.js"); 18 19 const lazy = {}; 20 ChromeUtils.defineESModuleGetters(lazy, { 21 wrapMoveFocus: "resource://devtools/client/shared/focus.mjs", 22 getFocusableElements: "resource://devtools/client/shared/focus.mjs", 23 }); 24 25 const AUTO_EXPAND_DEPTH = 0; 26 const NUMBER_OF_OFFSCREEN_ITEMS = 1; 27 28 /** 29 * A fast, generic, expandable and collapsible tree component. 30 * 31 * This tree component is fast: it can handle trees with *many* items. It only 32 * renders the subset of those items which are visible in the viewport. It's 33 * been battle tested on huge trees in the memory panel. We've optimized tree 34 * traversal and rendering, even in the presence of cross-compartment wrappers. 35 * 36 * This tree component doesn't make any assumptions about the structure of your 37 * tree data. Whether children are computed on demand, or stored in an array in 38 * the parent's `_children` property, it doesn't matter. We only require the 39 * implementation of `getChildren`, `getRoots`, `getParent`, and `isExpanded` 40 * functions. 41 * 42 * This tree component is well tested and reliable. See 43 * devtools/client/shared/components/test/mochitest/test_tree_* and its usage in 44 * the performance and memory panels. 45 * 46 * This tree component doesn't make any assumptions about how to render items in 47 * the tree. You provide a `renderItem` function, and this component will ensure 48 * that only those items whose parents are expanded and which are visible in the 49 * viewport are rendered. The `renderItem` function could render the items as a 50 * "traditional" tree or as rows in a table or anything else. It doesn't 51 * restrict you to only one certain kind of tree. 52 * 53 * The only requirement is that every item in the tree render as the same 54 * height. This is required in order to compute which items are visible in the 55 * viewport in constant time. 56 * 57 * ### Example Usage 58 * 59 * Suppose we have some tree data where each item has this form: 60 * 61 * { 62 * id: Number, 63 * label: String, 64 * parent: Item or null, 65 * children: Array of child items, 66 * expanded: bool, 67 * } 68 * 69 * Here is how we could render that data with this component: 70 * 71 * class MyTree extends Component { 72 * static get propTypes() { 73 * // The root item of the tree, with the form described above. 74 * return { 75 * root: PropTypes.object.isRequired 76 * }; 77 * } 78 * 79 * render() { 80 * return Tree({ 81 * itemHeight: 20, // px 82 * 83 * getRoots: () => [this.props.root], 84 * 85 * getParent: item => item.parent, 86 * getChildren: item => item.children, 87 * getKey: item => item.id, 88 * isExpanded: item => item.expanded, 89 * 90 * renderItem: (item, depth, isFocused, arrow, isExpanded) => { 91 * let className = "my-tree-item"; 92 * if (isFocused) { 93 * className += " focused"; 94 * } 95 * return dom.div( 96 * { 97 * className, 98 * // Apply 10px nesting per expansion depth. 99 * style: { marginLeft: depth * 10 + "px" } 100 * }, 101 * // Here is the expando arrow so users can toggle expansion and 102 * // collapse state. 103 * arrow, 104 * // And here is the label for this item. 105 * dom.span({ className: "my-tree-item-label" }, item.label) 106 * ); 107 * }, 108 * 109 * onExpand: item => dispatchExpandActionToRedux(item), 110 * onCollapse: item => dispatchCollapseActionToRedux(item), 111 * }); 112 * } 113 * } 114 */ 115 class Tree extends Component { 116 static get propTypes() { 117 return { 118 // Required props 119 120 // A function to get an item's parent, or null if it is a root. 121 // 122 // Type: getParent(item: Item) -> Maybe<Item> 123 // 124 // Example: 125 // 126 // // The parent of this item is stored in its `parent` property. 127 // getParent: item => item.parent 128 getParent: PropTypes.func.isRequired, 129 130 // A function to get an item's children. 131 // 132 // Type: getChildren(item: Item) -> [Item] 133 // 134 // Example: 135 // 136 // // This item's children are stored in its `children` property. 137 // getChildren: item => item.children 138 getChildren: PropTypes.func.isRequired, 139 140 // A function which takes an item and ArrowExpander component instance and 141 // returns a component, or text, or anything else that React considers 142 // renderable. 143 // 144 // Type: renderItem(item: Item, 145 // depth: Number, 146 // isFocused: Boolean, 147 // arrow: ReactComponent, 148 // isExpanded: Boolean) -> ReactRenderable 149 // 150 // Example: 151 // 152 // renderItem: (item, depth, isFocused, arrow, isExpanded) => { 153 // let className = "my-tree-item"; 154 // if (isFocused) { 155 // className += " focused"; 156 // } 157 // return dom.div( 158 // { 159 // className, 160 // style: { marginLeft: depth * 10 + "px" } 161 // }, 162 // arrow, 163 // dom.span({ className: "my-tree-item-label" }, item.label) 164 // ); 165 // }, 166 renderItem: PropTypes.func.isRequired, 167 168 // A function which returns the roots of the tree (forest). 169 // 170 // Type: getRoots() -> [Item] 171 // 172 // Example: 173 // 174 // // In this case, we only have one top level, root item. You could 175 // // return multiple items if you have many top level items in your 176 // // tree. 177 // getRoots: () => [this.props.rootOfMyTree] 178 getRoots: PropTypes.func.isRequired, 179 180 // A function to get a unique key for the given item. This helps speed up 181 // React's rendering a *TON*. 182 // 183 // Type: getKey(item: Item) -> String 184 // 185 // Example: 186 // 187 // getKey: item => `my-tree-item-${item.uniqueId}` 188 getKey: PropTypes.func.isRequired, 189 190 // A function to get whether an item is expanded or not. If an item is not 191 // expanded, then it must be collapsed. 192 // 193 // Type: isExpanded(item: Item) -> Boolean 194 // 195 // Example: 196 // 197 // isExpanded: item => item.expanded, 198 isExpanded: PropTypes.func.isRequired, 199 200 // The height of an item in the tree including margin and padding, in 201 // pixels. 202 itemHeight: PropTypes.number.isRequired, 203 204 // Optional props 205 206 // The currently focused item, if any such item exists. 207 focused: PropTypes.any, 208 209 // Handle when a new item is focused. 210 onFocus: PropTypes.func, 211 212 // The currently active (keyboard) item, if any such item exists. 213 active: PropTypes.any, 214 215 // Handle when item is activated with a keyboard (using Space or Enter) 216 onActivate: PropTypes.func, 217 218 // The currently shown item, if any such item exists. 219 shown: PropTypes.any, 220 221 // Indicates if pressing ArrowRight key should only expand expandable node 222 // or if the selection should also move to the next node. 223 preventNavigationOnArrowRight: PropTypes.bool, 224 225 // The depth to which we should automatically expand new items. 226 autoExpandDepth: PropTypes.number, 227 228 // Note: the two properties below are mutually exclusive. Only one of the 229 // label properties is necessary. 230 // ID of an element whose textual content serves as an accessible label for 231 // a tree. 232 labelledby: PropTypes.string, 233 // Accessibility label for a tree widget. 234 label: PropTypes.string, 235 236 // Optional event handlers for when items are expanded or collapsed. Useful 237 // for dispatching redux events and updating application state, maybe lazily 238 // loading subtrees from a worker, etc. 239 // 240 // Type: 241 // onExpand(item: Item) 242 // onCollapse(item: Item) 243 // 244 // Example: 245 // 246 // onExpand: item => dispatchExpandActionToRedux(item) 247 onExpand: PropTypes.func, 248 onCollapse: PropTypes.func, 249 }; 250 } 251 252 static get defaultProps() { 253 return { 254 autoExpandDepth: AUTO_EXPAND_DEPTH, 255 preventNavigationOnArrowRight: true, 256 }; 257 } 258 259 constructor(props) { 260 super(props); 261 262 this.state = { 263 scroll: 0, 264 height: window.innerHeight, 265 seen: new Set(), 266 mouseDown: false, 267 }; 268 269 this._onExpand = oncePerAnimationFrame(this._onExpand).bind(this); 270 this._onCollapse = oncePerAnimationFrame(this._onCollapse).bind(this); 271 this._onScroll = oncePerAnimationFrame(this._onScroll).bind(this); 272 this._focusPrevNode = oncePerAnimationFrame(this._focusPrevNode).bind(this); 273 this._focusNextNode = oncePerAnimationFrame(this._focusNextNode).bind(this); 274 this._focusParentNode = oncePerAnimationFrame(this._focusParentNode).bind( 275 this 276 ); 277 this._focusFirstNode = oncePerAnimationFrame(this._focusFirstNode).bind( 278 this 279 ); 280 this._focusLastNode = oncePerAnimationFrame(this._focusLastNode).bind(this); 281 282 this._autoExpand = this._autoExpand.bind(this); 283 this._preventArrowKeyScrolling = this._preventArrowKeyScrolling.bind(this); 284 this._updateHeight = this._updateHeight.bind(this); 285 this._onResize = this._onResize.bind(this); 286 this._dfs = this._dfs.bind(this); 287 this._dfsFromRoots = this._dfsFromRoots.bind(this); 288 this._focus = this._focus.bind(this); 289 this._activate = this._activate.bind(this); 290 this._onKeyDown = this._onKeyDown.bind(this); 291 } 292 293 componentDidMount() { 294 window.addEventListener("resize", this._onResize); 295 this._autoExpand(); 296 this._updateHeight(); 297 this._scrollItemIntoView(); 298 } 299 300 // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 301 UNSAFE_componentWillReceiveProps(nextProps) { 302 if (nextProps.autoExpandDepth != this.props.autoExpandDepth) { 303 this.setState({ seen: new Set() }); 304 } 305 this._autoExpand(); 306 this._updateHeight(); 307 } 308 309 shouldComponentUpdate(nextProps, nextState) { 310 const { scroll, height, seen, mouseDown } = this.state; 311 312 return ( 313 scroll !== nextState.scroll || 314 height !== nextState.height || 315 seen !== nextState.seen || 316 mouseDown === nextState.mouseDown 317 ); 318 } 319 320 componentDidUpdate(prevProps) { 321 if (prevProps.shown != this.props.shown) { 322 this._scrollItemIntoView(); 323 } 324 } 325 326 componentWillUnmount() { 327 window.removeEventListener("resize", this._onResize); 328 } 329 330 _scrollItemIntoView() { 331 const { shown } = this.props; 332 if (!shown) { 333 return; 334 } 335 336 this._scrollIntoView(shown); 337 } 338 339 _autoExpand() { 340 if (!this.props.autoExpandDepth) { 341 return; 342 } 343 344 // Automatically expand the first autoExpandDepth levels for new items. Do 345 // not use the usual DFS infrastructure because we don't want to ignore 346 // collapsed nodes. 347 const autoExpand = (item, currentDepth) => { 348 if ( 349 currentDepth >= this.props.autoExpandDepth || 350 this.state.seen.has(item) 351 ) { 352 return; 353 } 354 355 this.props.onExpand(item); 356 this.state.seen.add(item); 357 358 const children = this.props.getChildren(item); 359 const length = children.length; 360 for (let i = 0; i < length; i++) { 361 autoExpand(children[i], currentDepth + 1); 362 } 363 }; 364 365 const roots = this.props.getRoots(); 366 const length = roots.length; 367 for (let i = 0; i < length; i++) { 368 autoExpand(roots[i], 0); 369 } 370 } 371 372 _preventArrowKeyScrolling(e) { 373 switch (e.key) { 374 case "ArrowUp": 375 case "ArrowDown": 376 case "ArrowLeft": 377 case "ArrowRight": 378 preventDefaultAndStopPropagation(e); 379 break; 380 } 381 } 382 383 /** 384 * Updates the state's height based on clientHeight. 385 */ 386 _updateHeight() { 387 this.setState({ height: this.refs.tree.clientHeight }); 388 } 389 390 /** 391 * Perform a pre-order depth-first search from item. 392 */ 393 _dfs(item, maxDepth = Infinity, traversal = [], _depth = 0) { 394 traversal.push({ item, depth: _depth }); 395 396 if (!this.props.isExpanded(item)) { 397 return traversal; 398 } 399 400 const nextDepth = _depth + 1; 401 402 if (nextDepth > maxDepth) { 403 return traversal; 404 } 405 406 const children = this.props.getChildren(item); 407 const length = children.length; 408 for (let i = 0; i < length; i++) { 409 this._dfs(children[i], maxDepth, traversal, nextDepth); 410 } 411 412 return traversal; 413 } 414 415 /** 416 * Perform a pre-order depth-first search over the whole forest. 417 */ 418 _dfsFromRoots(maxDepth = Infinity) { 419 const traversal = []; 420 421 const roots = this.props.getRoots(); 422 const length = roots.length; 423 for (let i = 0; i < length; i++) { 424 this._dfs(roots[i], maxDepth, traversal); 425 } 426 427 return traversal; 428 } 429 430 /** 431 * Expands current row. 432 * 433 * @param {object} item 434 * @param {boolean} expandAllChildren 435 */ 436 _onExpand(item, expandAllChildren) { 437 if (this.props.onExpand) { 438 this.props.onExpand(item); 439 440 if (expandAllChildren) { 441 const children = this._dfs(item); 442 const length = children.length; 443 for (let i = 0; i < length; i++) { 444 this.props.onExpand(children[i].item); 445 } 446 } 447 } 448 } 449 450 /** 451 * Collapses current row. 452 * 453 * @param {object} item 454 */ 455 _onCollapse(item) { 456 if (this.props.onCollapse) { 457 this.props.onCollapse(item); 458 } 459 } 460 461 /** 462 * Scroll item into view. Depending on whether the item is already rendered, 463 * we might have to calculate the position of the item based on its index and 464 * the item height. 465 * 466 * @param {object} item 467 * The item to be scrolled into view. 468 * @param {number | undefined} index 469 * The index of the item in a full DFS traversal (ignoring collapsed 470 * nodes) or undefined. 471 * @param {object} options 472 * Optional information regarding item's requested alignement when 473 * scrolling. 474 */ 475 _scrollIntoView(item, index, options = {}) { 476 const treeElement = this.refs.tree; 477 if (!treeElement) { 478 return; 479 } 480 481 const element = document.getElementById(this.props.getKey(item)); 482 if (element) { 483 scrollIntoView(element, { ...options, container: treeElement }); 484 return; 485 } 486 487 if (index == null) { 488 // If index is not provided, determine item index from traversal. 489 const traversal = this._dfsFromRoots(); 490 index = traversal.findIndex(({ item: i }) => i === item); 491 } 492 493 if (index == null || index < 0) { 494 return; 495 } 496 497 const { itemHeight } = this.props; 498 const { clientHeight, scrollTop } = treeElement; 499 const elementTop = index * itemHeight; 500 let scrollTo; 501 if (scrollTop >= elementTop + itemHeight) { 502 scrollTo = elementTop; 503 } else if (scrollTop + clientHeight <= elementTop) { 504 scrollTo = elementTop; 505 } 506 507 if (scrollTo != undefined) { 508 treeElement.scrollTo({ 509 left: 0, 510 top: scrollTo, 511 }); 512 } 513 } 514 515 /** 516 * Sets the passed in item to be the focused item. 517 * 518 * @param {number} index 519 * The index of the item in a full DFS traversal (ignoring collapsed 520 * nodes). Ignored if `item` is undefined. 521 * 522 * @param {object | undefined} item 523 * The item to be focused, or undefined to focus no item. 524 */ 525 _focus(index, item, options = {}) { 526 if (item !== undefined && !options.preventAutoScroll) { 527 this._scrollIntoView(item, index, options); 528 } 529 530 if (this.props.active != null) { 531 this._activate(null); 532 if (this.refs.tree !== this.activeElement) { 533 this.refs.tree.focus(); 534 } 535 } 536 537 if (this.props.onFocus) { 538 this.props.onFocus(item); 539 } 540 } 541 542 _activate(item) { 543 if (this.props.onActivate) { 544 this.props.onActivate(item); 545 } 546 } 547 548 /** 549 * Update state height and tree's scrollTop if necessary. 550 */ 551 _onResize() { 552 // When tree size changes without direct user action, scroll top cat get re-set to 0 553 // (for example, when tree height changes via CSS rule change). We need to ensure that 554 // the tree's scrollTop is in sync with the scroll state. 555 if (this.state.scroll !== this.refs.tree.scrollTop) { 556 this.refs.tree.scrollTo({ left: 0, top: this.state.scroll }); 557 } 558 559 this._updateHeight(); 560 } 561 562 /** 563 * Fired on a scroll within the tree's container, updates 564 * the stored position of the view port to handle virtual view rendering. 565 */ 566 _onScroll() { 567 this.setState({ 568 scroll: Math.max(this.refs.tree.scrollTop, 0), 569 height: this.refs.tree.clientHeight, 570 }); 571 } 572 573 /** 574 * Handles key down events in the tree's container. 575 * 576 * @param {Event} e 577 */ 578 // eslint-disable-next-line complexity 579 _onKeyDown(e) { 580 if (this.props.focused == null) { 581 return; 582 } 583 584 // Allow parent nodes to use navigation arrows with modifiers. 585 if (e.altKey || e.ctrlKey || e.shiftKey || e.metaKey) { 586 return; 587 } 588 589 this._preventArrowKeyScrolling(e); 590 591 switch (e.key) { 592 case "ArrowUp": 593 this._focusPrevNode(); 594 break; 595 596 case "ArrowDown": 597 this._focusNextNode(); 598 break; 599 600 case "ArrowLeft": 601 if ( 602 this.props.isExpanded(this.props.focused) && 603 this.props.getChildren(this.props.focused).length 604 ) { 605 this._onCollapse(this.props.focused); 606 } else { 607 this._focusParentNode(); 608 } 609 break; 610 611 case "ArrowRight": 612 if ( 613 this.props.getChildren(this.props.focused).length && 614 !this.props.isExpanded(this.props.focused) 615 ) { 616 this._onExpand(this.props.focused); 617 } else if (!this.props.preventNavigationOnArrowRight) { 618 this._focusNextNode(); 619 } 620 break; 621 622 case "Home": 623 this._focusFirstNode(); 624 break; 625 626 case "End": 627 this._focusLastNode(); 628 break; 629 630 case "Enter": 631 case " ": 632 // On space or enter make focused tree node active. This means keyboard focus 633 // handling is passed on to the tree node itself. 634 if (this.refs.tree === this.activeElement) { 635 preventDefaultAndStopPropagation(e); 636 if (this.props.active !== this.props.focused) { 637 this._activate(this.props.focused); 638 } 639 } 640 break; 641 642 case "Escape": 643 preventDefaultAndStopPropagation(e); 644 if (this.props.active != null) { 645 this._activate(null); 646 } 647 648 if (this.refs.tree !== this.activeElement) { 649 this.refs.tree.focus(); 650 } 651 break; 652 } 653 } 654 655 get activeElement() { 656 return this.refs.tree.ownerDocument.activeElement; 657 } 658 659 _focusFirstNode() { 660 const traversal = this._dfsFromRoots(); 661 this._focus(0, traversal[0].item, { alignTo: "top" }); 662 } 663 664 _focusLastNode() { 665 const traversal = this._dfsFromRoots(); 666 const lastIndex = traversal.length - 1; 667 this._focus(lastIndex, traversal[lastIndex].item, { alignTo: "bottom" }); 668 } 669 670 /** 671 * Sets the previous node relative to the currently focused item, to focused. 672 */ 673 _focusPrevNode() { 674 // Start a depth first search and keep going until we reach the currently 675 // focused node. Focus the previous node in the DFS, if it exists. If it 676 // doesn't exist, we're at the first node already. 677 678 let prev; 679 let prevIndex; 680 681 const traversal = this._dfsFromRoots(); 682 const length = traversal.length; 683 for (let i = 0; i < length; i++) { 684 const item = traversal[i].item; 685 if (item === this.props.focused) { 686 break; 687 } 688 prev = item; 689 prevIndex = i; 690 } 691 692 if (prev === undefined) { 693 return; 694 } 695 696 this._focus(prevIndex, prev, { alignTo: "top" }); 697 } 698 699 /** 700 * Handles the down arrow key which will focus either the next child 701 * or sibling row. 702 */ 703 _focusNextNode() { 704 // Start a depth first search and keep going until we reach the currently 705 // focused node. Focus the next node in the DFS, if it exists. If it 706 // doesn't exist, we're at the last node already. 707 708 const traversal = this._dfsFromRoots(); 709 const length = traversal.length; 710 let i = 0; 711 712 while (i < length) { 713 if (traversal[i].item === this.props.focused) { 714 break; 715 } 716 i++; 717 } 718 719 if (i + 1 < traversal.length) { 720 this._focus(i + 1, traversal[i + 1].item, { alignTo: "bottom" }); 721 } 722 } 723 724 /** 725 * Handles the left arrow key, going back up to the current rows' 726 * parent row. 727 */ 728 _focusParentNode() { 729 const parent = this.props.getParent(this.props.focused); 730 if (!parent) { 731 return; 732 } 733 734 const traversal = this._dfsFromRoots(); 735 const length = traversal.length; 736 let parentIndex = 0; 737 for (; parentIndex < length; parentIndex++) { 738 if (traversal[parentIndex].item === parent) { 739 break; 740 } 741 } 742 743 this._focus(parentIndex, parent, { alignTo: "top" }); 744 } 745 746 render() { 747 const traversal = this._dfsFromRoots(); 748 749 // 'begin' and 'end' are the index of the first (at least partially) visible item 750 // and the index after the last (at least partially) visible item, respectively. 751 // `NUMBER_OF_OFFSCREEN_ITEMS` is removed from `begin` and added to `end` so that 752 // the top and bottom of the page are filled with the `NUMBER_OF_OFFSCREEN_ITEMS` 753 // previous and next items respectively, which helps the user to see fewer empty 754 // gaps when scrolling quickly. 755 const { itemHeight, active, focused } = this.props; 756 const { scroll, height } = this.state; 757 const begin = Math.max( 758 ((scroll / itemHeight) | 0) - NUMBER_OF_OFFSCREEN_ITEMS, 759 0 760 ); 761 const end = 762 Math.ceil((scroll + height) / itemHeight) + NUMBER_OF_OFFSCREEN_ITEMS; 763 const toRender = traversal.slice(begin, end); 764 const topSpacerHeight = begin * itemHeight; 765 const bottomSpacerHeight = Math.max(traversal.length - end, 0) * itemHeight; 766 767 const nodes = [ 768 dom.div({ 769 key: "top-spacer", 770 role: "presentation", 771 style: { 772 padding: 0, 773 margin: 0, 774 height: topSpacerHeight + "px", 775 }, 776 }), 777 ]; 778 779 for (let i = 0; i < toRender.length; i++) { 780 const index = begin + i; 781 const first = index == 0; 782 const last = index == traversal.length - 1; 783 const { item, depth } = toRender[i]; 784 const key = this.props.getKey(item); 785 nodes.push( 786 TreeNode({ 787 // We make a key unique depending on whether the tree node is in active or 788 // inactive state to make sure that it is actually replaced and the tabbable 789 // state is reset. 790 key: `${key}-${active === item ? "active" : "inactive"}`, 791 index, 792 first, 793 last, 794 item, 795 depth, 796 id: key, 797 renderItem: this.props.renderItem, 798 focused: focused === item, 799 active: active === item, 800 expanded: this.props.isExpanded(item), 801 hasChildren: !!this.props.getChildren(item).length, 802 onExpand: this._onExpand, 803 onCollapse: this._onCollapse, 804 // Since the user just clicked the node, there's no need to check if 805 // it should be scrolled into view. 806 onClick: () => 807 this._focus(begin + i, item, { preventAutoScroll: true }), 808 }) 809 ); 810 } 811 812 nodes.push( 813 dom.div({ 814 key: "bottom-spacer", 815 role: "presentation", 816 style: { 817 padding: 0, 818 margin: 0, 819 height: bottomSpacerHeight + "px", 820 }, 821 }) 822 ); 823 824 return dom.div( 825 { 826 className: "tree", 827 ref: "tree", 828 role: "tree", 829 tabIndex: "0", 830 onKeyDown: this._onKeyDown, 831 onKeyPress: this._preventArrowKeyScrolling, 832 onKeyUp: this._preventArrowKeyScrolling, 833 onScroll: this._onScroll, 834 onMouseDown: () => this.setState({ mouseDown: true }), 835 onMouseUp: () => this.setState({ mouseDown: false }), 836 onFocus: () => { 837 if (focused || this.state.mouseDown) { 838 return; 839 } 840 841 // Only set default focus to the first tree node if focused node is 842 // not yet set and the focus event is not the result of a mouse 843 // interarction. 844 this._focus(begin, toRender[0].item); 845 }, 846 onBlur: e => { 847 if (active != null) { 848 const { relatedTarget } = e; 849 if (!this.refs.tree.contains(relatedTarget)) { 850 this._activate(null); 851 } 852 } 853 }, 854 onClick: () => { 855 // Focus should always remain on the tree container itself. 856 this.refs.tree.focus(); 857 }, 858 "aria-label": this.props.label, 859 "aria-labelledby": this.props.labelledby, 860 "aria-activedescendant": focused && this.props.getKey(focused), 861 style: { 862 padding: 0, 863 margin: 0, 864 }, 865 }, 866 nodes 867 ); 868 } 869 } 870 871 /** 872 * An arrow that displays whether its node is expanded (▼) or collapsed 873 * (▶). When its node has no children, it is hidden. 874 */ 875 class ArrowExpanderClass extends Component { 876 static get propTypes() { 877 return { 878 item: PropTypes.any.isRequired, 879 visible: PropTypes.bool.isRequired, 880 expanded: PropTypes.bool.isRequired, 881 onCollapse: PropTypes.func.isRequired, 882 onExpand: PropTypes.func.isRequired, 883 }; 884 } 885 886 shouldComponentUpdate(nextProps) { 887 return ( 888 this.props.item !== nextProps.item || 889 this.props.visible !== nextProps.visible || 890 this.props.expanded !== nextProps.expanded 891 ); 892 } 893 894 render() { 895 const attrs = { 896 className: "arrow theme-twisty", 897 // To collapse/expand the tree rows use left/right arrow keys. 898 tabIndex: "-1", 899 "aria-hidden": true, 900 onClick: this.props.expanded 901 ? () => this.props.onCollapse(this.props.item) 902 : e => this.props.onExpand(this.props.item, e.altKey), 903 }; 904 905 if (this.props.expanded) { 906 attrs.className += " open"; 907 } 908 909 if (!this.props.visible) { 910 attrs.style = { 911 visibility: "hidden", 912 }; 913 } 914 915 return dom.div(attrs); 916 } 917 } 918 919 class TreeNodeClass extends Component { 920 static get propTypes() { 921 return { 922 id: PropTypes.any.isRequired, 923 focused: PropTypes.bool.isRequired, 924 active: PropTypes.bool.isRequired, 925 item: PropTypes.any.isRequired, 926 expanded: PropTypes.bool.isRequired, 927 hasChildren: PropTypes.bool.isRequired, 928 onExpand: PropTypes.func.isRequired, 929 index: PropTypes.number.isRequired, 930 first: PropTypes.bool, 931 last: PropTypes.bool, 932 onClick: PropTypes.func, 933 onCollapse: PropTypes.func.isRequired, 934 depth: PropTypes.number.isRequired, 935 renderItem: PropTypes.func.isRequired, 936 }; 937 } 938 939 constructor(props) { 940 super(props); 941 942 this._onKeyDown = this._onKeyDown.bind(this); 943 } 944 945 componentDidMount() { 946 // Make sure that none of the focusable elements inside the tree node container are 947 // tabbable if the tree node is not active. If the tree node is active and focus is 948 // outside its container, focus on the first focusable element inside. 949 const elms = lazy.getFocusableElements(this.refs.treenode); 950 if (elms.length === 0) { 951 return; 952 } 953 954 if (!this.props.active) { 955 elms.forEach(elm => elm.setAttribute("tabindex", "-1")); 956 return; 957 } 958 959 if (!elms.includes(this.refs.treenode.ownerDocument.activeElement)) { 960 elms[0].focus(); 961 } 962 } 963 964 _onKeyDown(e) { 965 const { target, key, shiftKey } = e; 966 967 if (key !== "Tab") { 968 return; 969 } 970 971 const focusMoved = !!lazy.wrapMoveFocus( 972 lazy.getFocusableElements(this.refs.treenode), 973 target, 974 shiftKey 975 ); 976 if (focusMoved) { 977 // Focus was moved to the begining/end of the list, so we need to prevent the 978 // default focus change that would happen here. 979 e.preventDefault(); 980 } 981 982 e.stopPropagation(); 983 } 984 985 render() { 986 const arrow = ArrowExpander({ 987 item: this.props.item, 988 expanded: this.props.expanded, 989 visible: this.props.hasChildren, 990 onExpand: this.props.onExpand, 991 onCollapse: this.props.onCollapse, 992 }); 993 994 const classList = ["tree-node", "div"]; 995 if (this.props.active) { 996 classList.push("tree-node-active"); 997 } 998 if (this.props.focused) { 999 classList.push("focused"); 1000 } 1001 1002 let ariaExpanded; 1003 if (this.props.hasChildren) { 1004 ariaExpanded = false; 1005 } 1006 if (this.props.expanded) { 1007 ariaExpanded = true; 1008 } 1009 1010 return dom.div( 1011 { 1012 id: this.props.id, 1013 className: classList.join(" "), 1014 role: "treeitem", 1015 ref: "treenode", 1016 "aria-level": this.props.depth + 1, 1017 onClick: this.props.onClick, 1018 onKeyDownCapture: this.props.active ? this._onKeyDown : undefined, 1019 "aria-expanded": ariaExpanded, 1020 "data-expanded": this.props.expanded ? "" : undefined, 1021 "data-depth": this.props.depth, 1022 style: { 1023 padding: 0, 1024 // This helps the CSS compute a margin based on the depth. 1025 "--tree-node-depth": this.props.depth, 1026 }, 1027 }, 1028 1029 this.props.renderItem( 1030 this.props.item, 1031 this.props.depth, 1032 this.props.focused, 1033 arrow, 1034 this.props.expanded 1035 ) 1036 ); 1037 } 1038 } 1039 1040 const ArrowExpander = createFactory(ArrowExpanderClass); 1041 const TreeNode = createFactory(TreeNodeClass); 1042 1043 /** 1044 * Create a function that calls the given function `fn` only once per animation 1045 * frame. 1046 * 1047 * @param {Function} fn 1048 * @returns {Function} 1049 */ 1050 function oncePerAnimationFrame(fn) { 1051 let animationId = null; 1052 let argsToPass = null; 1053 return function (...args) { 1054 argsToPass = args; 1055 if (animationId !== null) { 1056 return; 1057 } 1058 1059 animationId = requestAnimationFrame(() => { 1060 fn.call(this, ...argsToPass); 1061 animationId = null; 1062 argsToPass = null; 1063 }); 1064 }; 1065 } 1066 1067 module.exports = Tree;