chromium/ui/file_manager/file_manager/widgets/xf_tree_item.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 'chrome://resources/polymer/v3_0/paper-ripple/paper-ripple.js';
import './xf_icon.js';

import {css, customElement, html, ifDefined, property, type PropertyValues, query, state, styleMap, XfBase} from './xf_base.js';
import type {XfTree} from './xf_tree.js';
import {handleTreeSlotChange, isTreeItem, isXfTree} from './xf_tree_util.js';

/**
 * The number of pixels to indent per level.
 */
export const TREE_ITEM_INDENT = 20;

@customElement('xf-tree-item')
export class XfTreeItem extends XfBase {
  /**
   * `separator` attribute will show a top border for the tree item. It's
   * mainly used to identify this tree item is a start of the new section.
   */
  @property({type: Boolean, reflect: true}) separator = false;
  /**
   * Indicate if a tree item is disabled or not. Disabled tree item will have
   * a grey out color, can't be selected, can't get focus. It can still have
   * children, but it can't be expanded, and the expand icon will be hidden.
   */
  @property({type: Boolean, reflect: true}) disabled = false;
  /** Indicate if a tree item has been selected or not. */
  @property({type: Boolean, reflect: true}) selected = false;
  /** Indicate if a tree item has been expanded or not. */
  @property({type: Boolean, reflect: true}) expanded = false;
  /** Indicate if a tree item is in renaming mode or not. */
  @property({type: Boolean, reflect: true}) renaming = false;

  /**
   * A tree item will have children if the child tree items have been inserted
   * to its default slot. Only use `mayHaveChildren` if we want the tree item
   * to appeared as having children even without the actual child tree items
   * (e.g. no DOM children). This is mainly used when we asynchronously loads
   * child tree items.
   */
  @property({type: Boolean, reflect: true, attribute: 'may-have-children'})
  mayHaveChildren = false;

  /**
   * The icon of the tree item, will be displayed before the label text.
   * The icon value should come from `ICON_TYPES`, it will be passed
   * as `type` to a <xf-icon> widget to render an icon element.
   */
  @property({type: String, reflect: true}) icon = '';
  /**
   * The icon set is an object which contains multiple base64 image data, it
   * will be passed as `iconSet` property to `<xf-icon>` widget.
   * Note: `icon` will be ignored if `iconSet` is provided.
   */
  @property({attribute: false})
  iconSet: chrome.fileManagerPrivate.IconSet|null = null;
  /** The label text of the tree item. */
  @property({type: String, reflect: true}) label = '';

  static get events() {
    return {
      /** Triggers when a tree item has been expanded. */
      TREE_ITEM_EXPANDED: 'tree_item_expanded',
      /** Triggers when a tree item has been collapsed. */
      TREE_ITEM_COLLAPSED: 'tree_item_collapsed',
    } as const;
  }

  /** The level of the tree item, starting from 1. */
  get level(): number {
    return this.level_;
  }

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

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

  hasChildren(): boolean {
    return this.mayHaveChildren || this.items_.length > 0;
  }

  /**
   * Toggle the focusable for the item. We put the tabindex on the <li> element
   * instead of the whole <xf-tree-item> because <xf-tree-item> also includes
   * all children slots.
   *
   * We are delegate the focus to the <li> element in the shadow DOM, to make
   * sure the update is synchronous, we are operating on the DOM directly here
   * instead of updating this in the render() function.
   *
   * Note: "tabindex = -1" is also considered as "focusable" according to the
   * spec
   * https://html.spec.whatwg.org/multipage/interaction.html#the-tabindex-attribute,
   * so we need to remove the "tabindex" attribute below to make it
   * non-focusable.
   */
  toggleFocusable(focusable: boolean) {
    if (focusable) {
      this.$treeItem_.setAttribute('tabindex', '0');
    } else {
      this.$treeItem_.removeAttribute('tabindex');
    }
  }

  /**
   * Override focus() so we can manually focus the tree row element inside
   * shadow DOM.
   */
  override focus() {
    console.assert(
        !this.disabled,
        'Called focus() on a disabled XfTreeItem() isn\'t allowed');

    // Make sure this is the only focusable item in the tree before calling
    // focus().
    if (this.tree) {
      this.tree.focusedItem = this;
    }
    this.$treeItem_.focus();
  }

  /**
   * Return the parent XfTreeItem if there is one, for top level XfTreeItem
   * which doesn't have parent XfTreeItem, return null.
   */
  get parentItem(): XfTreeItem|null {
    let p = this.parentElement;
    while (p) {
      if (isTreeItem(p)) {
        return p;
      }
      if (isXfTree(p)) {
        return null;
      }
      p = p.parentElement;
    }
    return p;
  }

  get tree(): XfTree|null {
    let t = this.parentElement;
    while (t && !isXfTree(t)) {
      t = t.parentElement;
    }
    return t;
  }

  /**
   * Expands all parent items.
   */
  reveal() {
    let pi = this.parentItem;
    while (pi) {
      pi.expanded = true;
      pi = pi.parentItem;
    }
  }

  /**
   * This will be called when tree item is being set as a drop target.
   */
  doDropTargetAction() {
    this.expanded = true;
  }

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

  /**
   * Indicate the level of this tree item, we use it to calculate the padding
   * indentation. Note: "aria-level" can be calculated by DOM structure so
   * no need to provide it explicitly.
   */
  @state() private level_ = 1;

  @query('li') private $treeItem_!: HTMLLIElement;
  @query('.tree-row') private $treeRow_!: HTMLDivElement;
  @query('slot:not([name])') private $childrenSlot_!: HTMLSlotElement;

  /** The child tree items. */
  private items_: XfTreeItem[] = [];

  override render() {
    const showExpandIcon = this.hasChildren() && !this.disabled;
    const treeRowStyles = {
      paddingInlineStart:
          `max(0px, calc(var(--xf-tree-item-indent) * ${this.level_ - 1}px))`,
    };

    return html`
      <li
        class="tree-item"
        role="treeitem"
        aria-labelledby="tree-label"
        aria-selected=${this.selected}
        aria-expanded=${ifDefined(showExpandIcon ? this.expanded : undefined)}
        aria-disabled=${this.disabled}
      >
        <div class="tree-row-wrapper">
          <div
            class="tree-row"
            style=${styleMap(treeRowStyles)}
          >
            <paper-ripple></paper-ripple>
            <span class="expand-icon"></span>
            <xf-icon
              class="tree-label-icon"
              type=${ifDefined(this.iconSet ? undefined : this.icon)}
              .iconSet=${this.iconSet}
            ></xf-icon>
            <span class="tree-label" id="tree-label">${this.label || ''}</span>
            <slot name="rename"></slot>
            <slot name="trailingIcon"></slot>
          </div>
        </div>
        <ul
          class="tree-children"
          role="group"
        >
          <slot @slotchange=${this.onSlotChanged_}></slot>
        </ul>
      </li>
    `;
  }

  override connectedCallback() {
    super.connectedCallback();
    if (!this.tree) {
      throw new Error(
          '<xf-tree-item> can not be used without a parent <xf-tree>');
    }
  }

  /**
   * When <xf-tree-item> responds to the "contextmenu" event, the `e.target`
   * will always be the host element even if we put the focus on the inner
   * ".tree-row" element, this is because it's inside the shadow DOM. To make
   * sure the context menu shows in the correct location (when triggered by
   * keyboard), we need to expose this method to re-position the menu based on
   * the ".tree-row"'s bounding box. This method will be invoked by
   * `ContextMenuHandler`.
   */
  getRectForContextMenu(): DOMRect {
    return this.$treeRow_.getBoundingClientRect();
  }

  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);

    let updateScheduled = false;

    // If an expanded item's last children is deleted, update expanded property.
    if (this.items_.length === 0 && this.expanded) {
      this.expanded = false;
      updateScheduled = true;
    }

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

    if (!updateScheduled) {
      // Explicitly trigger an update because render() relies on hasChildren(),
      // which relies on `this.items_`.
      this.requestUpdate();
    }
  }

  override firstUpdated() {
    this.updateLevel_();
  }

  override updated(changedProperties: PropertyValues<this>) {
    super.updated(changedProperties);
    // For browser test use only.
    this.setAttribute('has-children', String(this.items_.length > 0));
    if (changedProperties.has('expanded')) {
      this.onExpandChanged_();
    }
    if (changedProperties.has('selected')) {
      this.onSelectedChanged_();
    }
  }

  private onExpandChanged_() {
    if (this.expanded) {
      const expandedEvent: TreeItemExpandedEvent =
          new CustomEvent(XfTreeItem.events.TREE_ITEM_EXPANDED, {
            bubbles: true,
            composed: true,
            detail: {item: this},
          });
      this.dispatchEvent(expandedEvent);
    } else {
      const collapseEvent: TreeItemCollapsedEvent =
          new CustomEvent(XfTreeItem.events.TREE_ITEM_COLLAPSED, {
            bubbles: true,
            composed: true,
            detail: {item: this},
          });
      this.dispatchEvent(collapseEvent);
    }
  }

  private onSelectedChanged_() {
    const tree = this.tree;
    if (this.selected) {
      this.reveal();
      if (tree) {
        tree.selectedItem = this;
      }
    } else {
      if (tree && tree.selectedItem === this) {
        tree.selectedItem = null;
      }
    }
  }

  /** Update the level of the tree item by traversing upwards. */
  private updateLevel_() {
    // Traverse upwards to determine the level.
    let level = 0;
    let current: XfTreeItem|null = this;
    while (current) {
      current = current.parentItem;
      level++;
    }
    this.level_ = level;
  }
}

function getCSS() {
  return css`
    :host {
      --xf-tree-item-indent: ${TREE_ITEM_INDENT};
      display: block;
    }

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

    li {
      display: block;
    }

    li:focus-visible {
      outline: none;
    }

    :host([separator])::before {
      border-bottom: 1px solid var(--cros-separator-color);
      content: '';
      display: block;
      margin: 8px 0;
      width: 100%;
    }

    /* We need this layer to make sure there's no gap between tree items, so
    when we drag items onto the tree items, it won't activate the parent tree
    item unexpectedly. */
    .tree-row-wrapper {
      cursor: pointer;
      padding: 4px;
    }

    .tree-row {
      align-items: center;
      border-inline-start-width: 0 !important;
      border-radius: 20px;
      box-sizing: border-box;
      color: var(--cros-sys-on_surface);
      display: flex;
      height: 40px;
      padding-inline-end: 12px;
      position: relative;
      user-select: none;
      white-space: nowrap;
    }

    :host(:not([selected]):not([disabled]):not([renaming]):not(:focus))
        .tree-row:hover {
      background-color: var(--cros-sys-hover_on_subtle);
    }

    :host([selected]) .tree-row {
      background-color: var(--cros-sys-primary);
      color: var(--cros-sys-on_primary);
    }

    :host([disabled]) .tree-row {
      color: var(--cros-sys-disabled);
      pointer-events: none;
    }

    :host-context(.focus-outline-visible):host(:focus) .tree-row {
      outline: 2px solid var(--cros-sys-focus_ring);
      outline-offset: 2px;
      z-index: 2;
    }

    :host-context(.pointer-active):host(:not([selected]):not([disabled]):not([renaming]):not(:focus))
        .tree-row:not(:hover):active {
      background-color: var(--cros-sys-hover_on_subtle);
    }

    :host-context(.pointer-active) .tree-row:not(:active) {
      cursor: default;
    }

    :host-context(.pointer-active):host(:not([selected]):not([disabled]):not([renaming]):not(:focus))
        .tree-row:not(:active):hover {
      background-color: unset;
    }

    :host-context(html.drag-drop-active):host(.denies) .tree-row {
      background-color: var(--cros-sys-error_container);
      color: var(--cros-sys-on_error_container);
    }

    :host-context(html.drag-drop-active):host(.accepts) .tree-row {
      background-color: var(--cros-sys-hover_on_subtle);
    }

    :host-context(html.drag-drop-active):host(.accepts[selected]) .tree-row {
      background-color: var(--cros-sys-primary);
    }

    .expand-icon {
      -webkit-mask-image: url(../foreground/images/files/ui/sort_desc.svg);
      -webkit-mask-position: center;
      -webkit-mask-repeat: no-repeat;
      background-color: currentColor;
      flex: none;
      height: 20px;
      margin-inline-start: 8px;
      position: relative;
      transform: rotate(-90deg);
      transition: all 150ms;
      visibility: hidden;
      width: 20px;
    }

    li[aria-expanded] .expand-icon {
      visibility: visible;
    }

    :host-context(html[dir=rtl]) .expand-icon {
      transform: rotate(90deg);
    }

    :host([expanded]) .expand-icon {
      transform: rotate(0);
    }

    .tree-label-icon {
      --xf-icon-color: var(--cros-sys-on_surface);
      flex: none;
    }

    :host([selected]) .tree-label-icon {
      --xf-icon-color: var(--cros-sys-on_primary)
    }

    :host([disabled]) .tree-label-icon {
      --xf-icon-color: var(--cros-sys-disabled);
    }

    .tree-label {
      display: block;
      flex: auto;
      font: var(--cros-button-2-font);
      margin-inline-end: 2px;
      margin-inline-start: 8px;
      min-width: 0;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: pre;
    }

    /** input is attached by DirectoryTreeNamingController. */
    slot[name="rename"]::slotted(input) {
      background-color: var(--cros-sys-app_base);
      border-radius: 4px;
      border: none;
      color: var(--cros-sys-on_surface);
      display: none;
      font: var(--cros-body-2-font);
      height: 20px;
      width: 100%;
      margin: 0 10px;
      outline: 2px solid var(--cros-sys-focus_ring);
      overflow: hidden;
      padding: 1px 8px;
    }

    :host([renaming]) slot[name="rename"]::slotted(input) {
      display: block;
    }

    :host([renaming]) .tree-label {
      display: none;
    }

    :host([selected]) slot[name="rename"]::slotted(input) {
      outline: 2px solid var(--cros-sys-inverse_primary);
    }

    paper-ripple {
      border-radius: 20px;
      color: var(--cros-sys-ripple_primary);
    }

    /* We need to ensure that even empty labels take up space. */
    .tree-label:empty::after {
      content: ' ';
      white-space: pre;
    }

    .tree-children {
      display: none;
    }

    :host([expanded]) .tree-children {
      display: block;
    }

    /* Trailing icon styles. */
    slot[name="trailingIcon"]::slotted(.align-right-icon) {
      --ink-color: var(--cros-sys-ripple_neutral_on_subtle);
      --iron-icon-height: 20px;
      --iron-icon-width: 20px;
      -ripple-opacity: 100%;
      border: none;
      border-radius: 20px;
      box-sizing: border-box;
      height: 40px;
      position: relative;
      right: -12px; /* Same as padding inline end of tree row. */
      width: 40px;
      z-index: 1;
    }

    :host-context([dir="rtl"]) slot[name="trailingIcon"]::slotted(.align-right-icon) {
      left: -12px; /* Same as padding inline end of tree row. */
      right: unset;
    }

    slot[name="trailingIcon"]::slotted(.external-link-icon iron-icon) {
      padding: 6px;
    }

    slot[name="trailingIcon"]::slotted(.root-eject) {
      --text-color: currentColor;
      --hover-bg-color: none;
      --ripple-opacity: 1;
      min-width: 32px;
      padding: 0;
    }

    slot[name="trailingIcon"]::slotted(.root-eject:focus) {
      outline: 2px solid var(--cros-sys-focus_ring);
      outline-offset: 2px;
    }

    :host([selected]) slot[name="trailingIcon"]::slotted(.root-eject:focus) {
      outline: 2px solid var(--cros-sys-inverse_primary);
    }

    slot[name="trailingIcon"]::slotted(.root-eject:active) {
      --ink-color: var(--cros-sys-ripple_neutral_on_subtle);
    }

    :host([selected]) slot[name="trailingIcon"]::slotted(.root-eject:active) {
      --ink-color: var(--cros-sys-ripple_neutral_on_prominent);
    }
  `;
}

/** Type of the tree item expanded custom event. */
export type TreeItemExpandedEvent = CustomEvent<{
  /** The tree item which has been expanded. */
  item: XfTreeItem,
}>;
/** Type of the tree item collapsed custom event. */
export type TreeItemCollapsedEvent = CustomEvent<{
  /** The tree item which has been collapsed. */
  item: XfTreeItem,
}>;

declare global {
  interface HTMLElementEventMap {
    [XfTreeItem.events.TREE_ITEM_EXPANDED]: TreeItemExpandedEvent;
    [XfTreeItem.events.TREE_ITEM_COLLAPSED]: TreeItemCollapsedEvent;
  }

  interface HTMLElementTagNameMap {
    'xf-tree-item': XfTreeItem;
  }
}