chromium/tools/binary_size/libsupersize/viewer/static/tree-ui.js

// Copyright 2018 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

'use strict';

/**
 * @fileoverview
 * Base class and helpers for tree navigation UI with collapsible branches and
 * keyboard navigation.
 */

/**
 * Base class for UI to render and navigate a tree structure. In DOM this is
 * rendered as a nested <ul> with <li> for each vertex. Each vertex can be:
 * * A "group" containing <a class="node"> for structure. A group can be
 *   expanded or unexpanded, and is controlled by the base class.
 * * A "leaf" containing <span class="node"> for data ldaves. A leaf is
 ,   controlled by derived classes.
 * Element rendering is done by derived classes, using custom templates.
 *
 * @template NODE_DATA_TYPE The data type of a tree node (groups and leaves)
 */
class TreeUi {
  /** @param {!HTMLUListElement} rootElt */
  constructor(rootElt) {
    /** @protected @const {!HTMLUListElement} rootElt */
    this.rootElt = rootElt;

    /**
     * @protected {HTMLCollectionOf<!TreeNodeElement>} Collection of all tree
     * node elements. Updates itself automatically.
     */
    this.liveNodeList =
        /** @type {HTMLCollectionOf<!TreeNodeElement>} */ (
            rootElt.getElementsByClassName('node'));

    /**
     * @protected @const {!WeakMap<HTMLElement, Readonly<NODE_DATA_TYPE>>}
     * Maps from UI nodes to data object to enable queries by event listeners
     * and other methods.
     */
    this.uiNodeToData = new WeakMap();

    /** @private @const {function(!MouseEvent): *} */
    this.boundToggleGroupElement = this.toggleGroupElement.bind(this);
  }

  /**
   * Decides whether |elt| is the node of a leaf or an unexpanded group.
   * @param {!HTMLElement} elt
   * @return {boolean}
   * @protected
   */
  isTerminalElement(elt) {
    return elt.classList.contains('node') &&
        elt.getAttribute('aria-expanded') === null;
  }

  /**
   * Sets focus to a new tree element while updating the element that last had
   * focus. The tabindex property is used to avoid needing to tab through every
   * single tree item in the page to reach other areas.
   * @param {?TreeNodeElement} nodeElt A tree node element.
   * @protected
   */
  setFocusElement(nodeElt) {
    const lastFocused = /** @type {HTMLElement} */ (document.activeElement);
    // If the last focused element was a tree node element, change its tabindex.
    if (this.uiNodeToData.has(lastFocused))
      lastFocused.tabIndex = -1;
    if (nodeElt) {
      nodeElt.tabIndex = 0;
      nodeElt.focus();
    }
  }

  /**
   * Same as setFocusElement(), but takes index into |liveNodeList| instead.
   * @param {number} index
   * @protected
   */
  setFocusElementByIndex(index) {
    this.setFocusElement(this.liveNodeList[index]);
  }

  /**
   * Creates an element for |nodeData| to represent a group or a leaf, which
   * depends on whether there are >= 1 children. May bind events.
   * @param {!NODE_DATA_TYPE} nodeData
   * @return {!{fragment: !DocumentFragment, isLeaf: boolean}}
   * @abstract @protected
   */
  makeGroupOrLeafFragment(nodeData) {
    return null;
  }

  /**
   * Creates an Element for |nodeData|, and binds click on group nodes to
   * toggleGroupElement().
   * @param {!NODE_DATA_TYPE} nodeData
   * @return {!DocumentFragment}
   * @public
   */
  makeNodeElement(nodeData) {
    const {fragment, isLeaf} = this.makeGroupOrLeafFragment(nodeData);
    const nodeElt = /** @type {TreeNodeElement} */ (
        assertNotNull(fragment.querySelector('.node')));

    // Associate clickable node & tree data.
    this.uiNodeToData.set(nodeElt, Object.freeze(nodeData));

    // Add click-to-toggle to group nodes.
    if (!isLeaf)
      nodeElt.addEventListener('click', this.boundToggleGroupElement);

    return fragment;
  }

  /**
   * Gets data for children of a group. Note that |link| is passed instead
   * @param {!HTMLAnchorElement} link
   * @return {!Promise<!Array<!NODE_DATA_TYPE>>}
   * @abstract @protected
   */
  async getGroupChildrenData(link) {
    return null;
  }

  /**
   * @param {!HTMLAnchorElement} link
   * @return {!HTMLLIElement}
   * @protected
   */
  getTreeItemFromLink(link) {
    // Canonical structure:
    // <li>                       <!-- |treeitem| -->
    //   <a class="node">...</a>  <!-- |link| -->
    //   <ul>...</ul>             <!-- |group| -->
    // </li>
    return /** @type {!HTMLLIElement} */ (link.parentElement);
  }

  /**
   * @param {!HTMLAnchorElement} link
   * @return {!HTMLUListElement}
   * @protected
   */
  getGroupFromLink(link) {
    return /** @type {!HTMLUListElement} */ (link.nextElementSibling);
  }

  /**
   * @param {!HTMLElement} link
   * @param {!Array<DocumentFragment>} childrenElements
   * @protected
   */
  autoExpandAttentionWorthyChild(link, childrenElements) {
    if (childrenElements.length === 1) {
      // Open inner element if it only has a single child; this ensures nodes
      // like "java"->"com"->"google" are opened all at once.
      const node = /** @type {!TreeNodeElement} */ (
          childrenElements[0].querySelector('.node'));
      node.click();
    }
  }

  /**
   * Populates |link| with
   * @param {!HTMLAnchorElement} link
   * @protected
   */
  async expandGroupElement(link) {
    const childrenData = await this.getGroupChildrenData(link);
    const newElements = childrenData.map((data) => this.makeNodeElement(data));
    this.autoExpandAttentionWorthyChild(link, newElements);
    const newElementsFragment = dom.createFragment(newElements);
    requestAnimationFrame(() => {
      this.getGroupFromLink(link).appendChild(newElementsFragment);
    });
  }

  /**
   * Click event handler to expand or close a group node.
   * @param {Event} event
   * @protected
   */
  async toggleGroupElement(event) {
    event.preventDefault();
    const link = /** @type {!HTMLAnchorElement} */ (event.currentTarget);
    const treeitem = this.getTreeItemFromLink(link);
    const group = this.getGroupFromLink(link);

    const isExpanded = treeitem.getAttribute('aria-expanded') === 'true';
    if (isExpanded) {
      // Take keyboard focus from descendent node.
      const lastFocused = /** @type {HTMLElement} */ (document.activeElement);
      if (lastFocused && group.contains(lastFocused))
        this.setFocusElement(link);
      // Update DOM.
      treeitem.setAttribute('aria-expanded', 'false');
      dom.replace(group, null);
    } else {
      treeitem.setAttribute('aria-expanded', 'true');
      await this.expandGroupElement(link);
    }
  }

  /**
   * Helper to handle tree navigation on keydown event.
   * @param {!KeyboardEvent} event Event passed from keydown event listener.
   * @param {!TreeNodeElement} link Tree node element, either a group or leaf.
   *     Trees use <a> tags, leaves use <span> tags. For example, see
   *     #tmpl-symbol-tree-group and #tmpl-symbol-tree-leaf.
   * @param {number} focusIndex
   * @return {boolean} Whether the event is handled.
   * @protected
   */
  handleKeyNavigationCommon(event, link, focusIndex) {
    /** Focuses the tree element immediately following this one. */
    const focusNext = () => {
      if (focusIndex > -1 && focusIndex < this.liveNodeList.length - 1) {
        event.preventDefault();
        this.setFocusElementByIndex(focusIndex + 1);
      }
    };

    /** Opens or closes the tree element. */
    const toggle = () => {
      event.preventDefault();
      /** @type {HTMLAnchorElement} */ (link).click();
    };

    switch (event.key) {
      // Space should act like clicking or pressing enter & toggle the tree.
      case ' ':
        toggle();
        return true;
      // Move to previous focusable node.
      case 'ArrowUp':
        if (focusIndex > 0) {
          event.preventDefault();
          this.setFocusElementByIndex(focusIndex - 1);
        }
        return true;
      // Move to next focusable node.
      case 'ArrowDown':
        focusNext();
        return true;
      // If closed tree, open tree. Otherwise, move to first child.
      case 'ArrowRight': {
        const expanded = link.parentElement.getAttribute('aria-expanded');
        // Handle groups only (leaves do not have aria-expanded property).
        if (expanded !== null) {
          if (expanded === 'true') {
            focusNext();
          } else {
            toggle();
          }
        }
        return true;
      }
      // If opened tree, close tree. Otherwise, move to parent.
      case 'ArrowLeft': {
        const isExpanded =
            link.parentElement.getAttribute('aria-expanded') === 'true';
        if (isExpanded) {
          toggle();
        } else {
          const groupList = link.parentElement.parentElement;
          if (groupList.getAttribute('role') === 'group') {
            event.preventDefault();
            /** @type {HTMLAnchorElement} */
            const parentLink = /** @type {HTMLAnchorElement} */ (
                groupList.previousElementSibling);
            this.setFocusElement(parentLink);
          }
        }
        return true;
      }
      // Focus first node.
      case 'Home':
        event.preventDefault();
        this.setFocusElementByIndex(0);
        return true;
      // Focus last node on screen.
      case 'End':
        event.preventDefault();
        this.setFocusElementByIndex(this.liveNodeList.length - 1);
        return true;
      // Expand all sibling nodes.
      case '*':
        const groupList = link.parentElement.parentElement;
        if (groupList.getAttribute('role') === 'group') {
          event.preventDefault();
          for (const li of groupList.children) {
            if (li.getAttribute('aria-expanded') !== 'true') {
              const otherLink =
                  /** @type {!TreeNodeElement} */ (li.querySelector('.node'));
              otherLink.click();
            }
          }
        }
        return true;
      // Remove focus from the tree view.
      case 'Escape':
        link.blur();
        return true;
    }

    return false;
  }

  /**
   * Handler for gaining focus relative to other TreeUi instances.
   * @protected
   */
  onTreeFocus() {}

  /**
   * Handler for losing focus relative to other TreeUi instances, i.e., this
   * does NOT fire when non-TreeUi UI elements gain focus.
   * @protected
   */
  onTreeBlur() {}

  /** @public */
  focus() {
    if (TreeUi.activeTreeUi !== this) {
      if (TreeUi.activeTreeUi)
        TreeUi.activeTreeUi.onTreeBlur();
      TreeUi.activeTreeUi = this;
      TreeUi.activeTreeUi.onTreeFocus();
    }
  }

  /** @public */
  init() {
    // Each instance contributes to managing focus / blur dynamics.
    this.rootElt.addEventListener('click', () => this.focus());
  }
}

/** @type {?TreeUi} */
TreeUi.activeTreeUi = null;