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