chromium/ui/file_manager/file_manager/widgets/xf_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 {isRTL} from 'chrome://resources/ash/common/util.js';

import {css, customElement, html, query, state, XfBase} from './xf_base.js';
import type {XfTreeItem} from './xf_tree_item.js';
import {type TreeItemCollapsedEvent} from './xf_tree_item.js';
import {handleTreeSlotChange, isTreeItem} from './xf_tree_util.js';

/**
 * <xf-tree> is the container of the <xf-tree-item> elements. An example
 * DOM structure is like this:
 *
 * <xf-tree>
 *   <xf-tree-item>
 *     <xf-tree-item></xf-tree-item>
 *   </xf-tree-item>
 *   <xf-tree-item></xf-tree-item>
 * </xf-tree>
 *
 * The selection and focus of <xf-tree-item> is controlled in <xf-tree>,
 * this is because we need to make sure only one item is being selected or
 * focused.
 *
 */
@customElement('xf-tree')
export class XfTree extends XfBase {
  static get events() {
    return {
      /** Triggers when a tree item has been selected. */
      TREE_SELECTION_CHANGED: 'tree_selection_changed',
    } as const;
  }

  /** Return the selected tree item, could be null. */
  get selectedItem(): XfTreeItem|null {
    return this.selectedItem_;
  }
  set selectedItem(item: XfTreeItem|null) {
    this.selectItem_(item);
  }

  /** Return the focused tree item, could be null. */
  get focusedItem(): XfTreeItem|null {
    return this.focusedItem_;
  }
  set focusedItem(item: XfTreeItem|null) {
    this.makeItemFocusable_(item);
  }

  /** The child tree items. */
  get items(): XfTreeItem[] {
    return this.items_;
  }

  /** The child tree items which can be tabbed/focused into. */
  get tabbableItems(): XfTreeItem[] {
    return this.items_.filter(item => !item.disabled);
  }

  /** The default unnamed slot to let consumer pass children tree items. */
  @query('slot') private $childrenSlot_!: HTMLSlotElement;

  /** The child tree items. */
  private items_: XfTreeItem[] = [];
  /**
   * Maintain these in the tree level so we can make sure at most one tree item
   * can be selected/focused.
   */
  private selectedItem_: XfTreeItem|null = null;
  private focusedItem_: XfTreeItem|null = null;

  /**
   * Value to set aria-setsize, which is the number of the top level child tree
   * items.
   */
  @state() private ariaSetSize_ = 0;

  static override get styles() {
    return getCSS();
  }

  /**
   * The <xf-tree> itself is not focusable, it will delegate the focus down to
   * its `focusedItem_`.
   *
   * Note: previously we use `delegatesFocus: true` in the shadowRootOptions,
   * but it triggers weird behavior b/320580121, hence the override here.
   */
  override focus() {
    if (this.focusedItem_) {
      this.focusedItem_.focus();
    }
  }

  override render() {
    return html`
      <ul
        class="tree"
        role="tree"
        aria-setsize=${this.ariaSetSize_}
        @tree_item_collapsed=${this.onTreeItemCollapsed_}
      >
        <slot @slotchange=${this.onSlotChanged_}></slot>
      </ul>
    `;
  }

  override connectedCallback(): void {
    super.connectedCallback();
    // Binding all these events at the host element level because the blank
    // space of the tree doesn't belong to the root <ul> element.
    this.addEventListener('contextmenu', this.onHostContextMenu_.bind(this));
    this.addEventListener('click', this.onHostClicked_.bind(this));
    this.addEventListener('dblclick', this.onHostDblClicked_.bind(this));
    this.addEventListener('mousedown', this.onHostMouseDown_.bind(this));
    this.addEventListener('keydown', this.onHostKeyDown_.bind(this));
  }

  private onSlotChanged_() {
    const oldItems = new Set(this.items_);
    // Update `items_` every time when the children slot changes (e.g.
    // add/remove).
    this.items_ = this.$childrenSlot_.assignedElements().filter(isTreeItem);
    this.ariaSetSize_ = this.tabbableItems.length;

    const newItems = new Set(this.items_);
    handleTreeSlotChange(this, oldItems, newItems);
  }

  /**
   * Handles the collapse event of the tree item.
   */
  private onTreeItemCollapsed_(e: TreeItemCollapsedEvent) {
    const treeItem = e.detail.item;
    // If the currently focused tree item (`oldFocusedItem`) is a descent of
    // another tree item (`treeItem`) which is going to be collapsed, we need to
    // mark the ancestor tree item (`this`) as focused.
    if (this.focusedItem_ !== treeItem) {
      const oldFocusedItem = this.focusedItem_;
      if (oldFocusedItem && treeItem.contains(oldFocusedItem)) {
        this.makeItemFocusable_(treeItem);
      }
    }
  }

  /** Called when the user clicks within the host element. */
  private onHostClicked_(e: MouseEvent) {
    // Mouse right click won't trigger click event, so this check is not
    // necessary in real scenario. This is mainly for the browser test because
    // waitAndRightClickEvent will actually trigger a click event with button=2.
    if (e.button === 2) {
      return;
    }

    // Stop if the the click target is not a tree item.
    const treeItem = e.target as XfTreeItem;
    if (treeItem && !isTreeItem(treeItem)) {
      // Clicking the non tree item area should focus the whole tree, which will
      // delegate the focus to the currently focusable child tree item.
      this.focus();
      return;
    }

    if (treeItem.disabled) {
      e.stopImmediatePropagation();
      e.preventDefault();
      return;
    }

    // Use composed path to know which element inside the shadow root
    // has been clicked.
    const innerClickTarget = e.composedPath()[0] as HTMLElement;
    if (innerClickTarget.className === 'expand-icon') {
      treeItem.expanded = !treeItem.expanded;
    } else {
      treeItem.selected = true;
    }
    treeItem.focus();
  }

  /** Called when the user double clicks within the host element. */
  private onHostDblClicked_(e: MouseEvent) {
    // Stop if the the click target is not a tree item.
    const treeItem = e.target as XfTreeItem;
    if (treeItem && !isTreeItem(treeItem)) {
      // Double clicking the non tree item area should focus the whole tree,
      // which will delegate the focus to the currently focusable child tree
      // item.
      this.focus();
      return;
    }

    if (treeItem.disabled) {
      e.stopImmediatePropagation();
      e.preventDefault();
      return;
    }

    // Use composed path to know which element inside the shadow root
    // has been clicked.
    const innerClickTarget = e.composedPath()[0] as HTMLElement;
    if (innerClickTarget.className !== 'expand-icon' &&
        treeItem.hasChildren()) {
      treeItem.expanded = !treeItem.expanded;
      treeItem.focus();
    }
  }

  /** Called when mouse down event happens within the host element. */
  private onHostMouseDown_(e: MouseEvent) {
    // Only handle the right click here, left click is handled by the click
    // handler above.
    if (e.button !== 2) {
      return;
    }

    // Stop if the the click target is not a tree item.
    const treeItem = e.target as XfTreeItem;
    if (treeItem && !isTreeItem(treeItem)) {
      // Right clicking the non tree item area should focus the whole tree,
      // which will delegate the focus to the currently focusable child tree
      // item.
      this.focus();
      return;
    }

    if (treeItem.disabled) {
      e.stopImmediatePropagation();
      e.preventDefault();
      return;
    }

    treeItem.focus();
  }

  /** Called when a context menu event happens within the host element. */
  private onHostContextMenu_(e: MouseEvent) {
    // Delegate the tree level contextmenu event to the focused child tree item.
    // Note: tree item contextmenu event will never arrive here because the
    // event listener registered in ContextMenuHandler stops propagation after
    // showing the context menu. So the handler here is only for right clicking
    // on the blank space area (e.g. outside the root <ul> element).
    if (this.focusedItem_) {
      const domRect = this.focusedItem_.getRectForContextMenu();
      // Calculate the center point of the tree item, so <xf-tree-item> knows
      // where to show the context menu pop-up.
      const x = domRect.x + (domRect.width / 2);
      const y = domRect.y + (domRect.height / 2);
      this.focusedItem_.dispatchEvent(
          new PointerEvent(e.type, {...e, clientX: x, clientY: y}));
    }
  }

  /**
   * Handle the keydown within the host element, this mainly handles the
   * navigation and the selection with the keyboard.
   */
  private onHostKeyDown_(e: KeyboardEvent) {
    if (e.ctrlKey) {
      return;
    }
    // We allow repeated keydown (e.g. hold the key without releasing to trigger
    // event multiple times) only for ArrowUp/ArrowDown, so users can use hold
    // arrow up/down to quickly navigate to the tree items far away.
    const allowRepeat = e.key === 'ArrowUp' || e.key === 'ArrowDown';
    if (e.repeat && !allowRepeat) {
      return;
    }

    if (!this.focusedItem_) {
      return;
    }

    if (this.tabbableItems.length === 0) {
      return;
    }

    let itemToFocus: XfTreeItem|null|undefined = null;
    switch (e.key) {
      case 'Enter':
      case ' ':
        this.selectItem_(this.focusedItem_);
        break;
      case 'ArrowUp':
        itemToFocus = this.getPreviousItem_(this.focusedItem_);
        break;
      case 'ArrowDown':
        itemToFocus = this.getNextItem_(this.focusedItem_);
        break;
      case 'ArrowLeft':
      case 'ArrowRight':
        // Don't let back/forward keyboard shortcuts be used.
        if (e.altKey) {
          break;
        }

        const expandKey = isRTL() ? 'ArrowLeft' : 'ArrowRight';
        if (e.key === expandKey) {
          if (this.focusedItem_.hasChildren() && !this.focusedItem_.expanded) {
            this.focusedItem_.expanded = true;
          } else {
            itemToFocus = this.focusedItem_.tabbableItems[0];
          }
        } else {
          if (this.focusedItem_.expanded) {
            this.focusedItem_.expanded = false;
          } else {
            itemToFocus = this.focusedItem_.parentItem;
          }
        }
        break;
      case 'Home':
        itemToFocus = this.tabbableItems[0];
        break;
      case 'End':
        itemToFocus = this.tabbableItems[this.tabbableItems.length - 1];
        break;
    }

    if (itemToFocus) {
      itemToFocus.focus();
      e.preventDefault();
    }
  }

  /**
   * Helper function that returns the next tabbable tree item.
   */
  private getNextItem_(item: XfTreeItem): XfTreeItem|null {
    if (item.expanded && item.tabbableItems.length > 0) {
      return item.tabbableItems[0]!;
    }

    return this.getNextHelper_(item);
  }

  /**
   * Another helper function that returns the next tabbable tree item.
   */
  private getNextHelper_(item: XfTreeItem|null): XfTreeItem|null {
    if (!item) {
      return null;
    }

    const nextSibling = item.nextElementSibling as XfTreeItem | null;
    if (nextSibling) {
      if (nextSibling.disabled) {
        return this.getNextHelper_(nextSibling);
      }
      return nextSibling;
    }
    return this.getNextHelper_(item.parentItem);
  }

  /**
   * Helper function that returns the previous tabbable tree item.
   */
  private getPreviousItem_(item: XfTreeItem): XfTreeItem|null {
    let previousSibling = item.previousElementSibling as XfTreeItem | null;
    while (previousSibling && previousSibling.disabled) {
      previousSibling =
          previousSibling.previousElementSibling as XfTreeItem | null;
    }
    if (previousSibling) {
      return this.getLastHelper_(previousSibling);
    }
    return item.parentItem;
  }

  /**
   * Helper function that returns the last tabbable tree item in the subtree.
   */
  private getLastHelper_(item: XfTreeItem|null): XfTreeItem|null {
    if (!item) {
      return null;
    }
    if (item.expanded && item.tabbableItems.length > 0) {
      const lastChild = item.tabbableItems[item.tabbableItems.length - 1]!;
      return this.getLastHelper_(lastChild);
    }
    return item;
  }

  /**
   * Make `itemToSelect` become the selected item in the tree, this will
   * also unselect the previously selected tree item to make sure at most
   * one tree item is selected in the tree.
   */
  private selectItem_(itemToSelect: XfTreeItem|null) {
    const previousSelectedItem = this.selectedItem_;
    if (itemToSelect === previousSelectedItem) {
      return;
    }
    if (previousSelectedItem) {
      previousSelectedItem.selected = false;
    }
    this.selectedItem_ = itemToSelect;
    if (this.selectedItem_) {
      this.selectedItem_.selected = true;
      // When tree item gets selected programmatically (e.g. not through
      // mouse/keyboard), there might be other elements on the page which have
      // the focus, we don't want to steal the focus, so all we do here is to
      // make the item focusable.
      this.makeItemFocusable_(this.selectedItem_);
    }
    const selectionChangeEvent: TreeSelectedChangedEvent =
        new CustomEvent(XfTree.events.TREE_SELECTION_CHANGED, {
          bubbles: true,
          composed: true,
          detail: {
            previousSelectedItem,
            selectedItem: this.selectedItem,
          },
        });
    this.dispatchEvent(selectionChangeEvent);
  }

  /**
   * Make `itemToFocus` become the focusable, this will also make the previously
   * focused item non-focusable so we can make sure only 1 tree item is
   * focusable, this is essential for "delegatesFocus" to work.
   *
   * Note: this method only make the item to be focusable, it won't actually
   * focus the item, we need to call `.focus()` after to focus it.
   */
  private makeItemFocusable_(itemToFocus: XfTreeItem|null) {
    const previousFocusedItem = this.focusedItem_;
    if (previousFocusedItem === itemToFocus) {
      return;
    }
    if (previousFocusedItem) {
      previousFocusedItem.toggleFocusable(false);
    }
    this.focusedItem_ = itemToFocus;
    if (this.focusedItem_) {
      this.focusedItem_.toggleFocusable(true);
    }
  }
}

function getCSS() {
  return css`
    :host {
      display: block;
    }

    ul {
      list-style: none;
      margin: 0;
      padding: 0;
    }
  `;
}

/** Type of the tree item selection custom event. */
export type TreeSelectedChangedEvent = CustomEvent<{
  /** The tree item which has been selected previously. */
  previousSelectedItem: XfTreeItem | null,
  /** The tree item which has been selected now. */
  selectedItem: XfTreeItem | null,
}>;

declare global {
  interface HTMLElementEventMap {
    [XfTree.events.TREE_SELECTION_CHANGED]: TreeSelectedChangedEvent;
  }

  interface HTMLElementTagNameMap {
    'xf-tree': XfTree;
  }
}