chromium/ui/webui/resources/cr_elements/cr_tree/cr_tree.ts

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

import {assert, assertNotReached} from '//resources/js/assert.js';
import {isMac} from '//resources/js/platform.js';

import {getTemplate} from './cr_tree.html.js';
import {CrTreeBaseElement} from './cr_tree_base.js';
import type {CrTreeItemElement} from './cr_tree_item.js';
import {SELECTED_ATTR} from './cr_tree_item.js';

/**
 * @fileoverview cr-tree is a container for a tree structure. Items can be added
 * or removed from the tree using the add/addAt/removeItem methods. Adding items
 * declaratively is not currently supported, as this class is primarily intended
 * to replace cr.ui.Tree, which is used for cases of creating trees at runtime
 * (e.g. from backend data).
 */

/**
 * Helper function that returns the next visible tree item.
 */
function getNext(item: CrTreeBaseElement): CrTreeBaseElement|null {
  if (item.expanded) {
    const firstChild = item.items[0];
    if (firstChild) {
      return firstChild;
    }
  }

  return getNextHelper(item);
}

/**
 * Another helper function that returns the next visible tree item.
 */
function getNextHelper(item: CrTreeBaseElement|null): CrTreeBaseElement|null {
  if (!item) {
    return null;
  }

  const nextSibling = item.nextElementSibling;
  if (nextSibling) {
    assert(nextSibling.tagName === 'CR-TREE-ITEM');
    return nextSibling as CrTreeBaseElement;
  }
  const parent = item.parentItem;
  if (!parent || parent.tagName === 'CR-TREE') {
    return null;
  }
  return getNextHelper(item.parentItem);
}

/**
 * Helper function that returns the previous visible tree item.
 */
function getPrevious(item: CrTreeBaseElement): CrTreeBaseElement|null {
  const previousSibling = item.previousElementSibling;
  if (previousSibling && previousSibling.tagName === 'CR-TREE-ITEM') {
    return getLastHelper(previousSibling as CrTreeBaseElement);
  }
  return item.parentItem;
}

/**
 * Helper function that returns the last visible tree item in the subtree.
 */
function getLastHelper(item: CrTreeBaseElement): CrTreeBaseElement|null {
  if (item.expanded && item.hasChildren) {
    const lastChild = item.items[item.items.length - 1]!;
    return getLastHelper(lastChild);
  }
  return item;
}

export class CrTreeElement extends CrTreeBaseElement {
  static override get template() {
    return getTemplate();
  }

  private selectedItem_: CrTreeBaseElement|null = null;

  /**
   * Initializes the element.
   */
  connectedCallback() {
    if (!this.hasAttribute('role')) {
      this.setAttribute('role', 'tree');
    }

    this.addEventListener('keydown', this.handleKeyDown.bind(this));
  }

  // CrTreeBase implementation:
  /**
   * The depth of the node. This is 0 for the tree itself.
   */
  override get depth(): number {
    return 0;
  }

  override get itemsRoot(): DocumentFragment|HTMLElement {
    return this.shadowRoot!;
  }

  // These two methods should never be called for the tree itself.
  override set depth(_depth: number) {
    assertNotReached();
  }

  override setParent(_parent: CrTreeBaseElement) {
    assertNotReached();
  }

  /**
   * The selected tree item or null if none.
   */
  override get selectedItem(): CrTreeBaseElement|null {
    return this.selectedItem_ || null;
  }

  override set selectedItem(item: CrTreeBaseElement|null) {
    const oldSelectedItem = this.selectedItem_;
    if (oldSelectedItem !== item) {
      // Set the selectedItem_ before deselecting the old item since we only
      // want one change when moving between items.
      this.selectedItem_ = item;

      if (oldSelectedItem) {
        oldSelectedItem.toggleAttribute(SELECTED_ATTR, false);
      }

      if (item) {
        item.toggleAttribute(SELECTED_ATTR, true);
        if (item.id) {
          this.setAttribute('aria-activedescendant', item.id);
        }
        if (this.matches(':focus-within') || this.shadowRoot!.activeElement) {
          (item as CrTreeItemElement).rowElement.focus();
        }
      } else {
        this.removeAttribute('aria-activedescendant');
      }

      this.dispatchEvent(
          new CustomEvent('cr-tree-change', {bubbles: true, composed: true}));
    }
  }

  override addAt(child: CrTreeBaseElement, index: number) {
    super.addAt(child, index);
    // aria-owns doesn't work well for the tree because the treeitem role is
    // set on the rowElement within cr-tree-item's shadow DOM. Set the size
    // here, so the correct number of items is read.
    this.setAttribute('aria-setsize', this.items.length.toString());
  }

  /**
   * Handles keydown events on the tree and updates selection and exanding
   * of tree items.
   */
  handleKeyDown(e: KeyboardEvent) {
    let itemToSelect: CrTreeBaseElement|null = null;
    if (e.ctrlKey) {
      return;
    }

    const item = this.selectedItem;
    if (!item) {
      return;
    }

    const rtl = getComputedStyle(item).direction === 'rtl';

    switch (e.key) {
      case 'ArrowUp':
        itemToSelect = getPrevious(item);
        break;
      case 'ArrowDown':
        itemToSelect = getNext(item);
        break;
      case 'ArrowLeft':
      case 'ArrowRight':
        // Don't let back/forward keyboard shortcuts be used.
        if (!isMac && e.altKey || isMac && e.metaKey) {
          break;
        }

        if (e.key === 'ArrowLeft' && !rtl || e.key === 'ArrowRight' && rtl) {
          if (item.expanded) {
            item.expanded = false;
          } else {
            itemToSelect = item.parentItem;
          }
        } else {
          if (!item.expanded) {
            item.expanded = true;
          } else {
            itemToSelect = item.items[0] || null;
          }
        }
        break;
      case 'Home':
        itemToSelect = this.items[0] || null;
        break;
      case 'End':
        itemToSelect = this.items[this.items.length - 1] || null;
        break;
    }

    if (itemToSelect) {
      itemToSelect.toggleAttribute(SELECTED_ATTR, true);
      e.preventDefault();
    }
  }

  setIconVisibility(visibility: string) {
    this.setAttribute('icon-visibility', visibility);
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'cr-tree': CrTreeElement;
  }
}

customElements.define('cr-tree', CrTreeElement);