tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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;