chromium/chrome/browser/resources/bookmarks/folder_node.ts

// Copyright 2016 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/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/cr_elements/cr_nav_menu_item_style.css.js';
import 'chrome://resources/cr_elements/cr_ripple/cr_ripple.js';
import 'chrome://resources/cr_elements/cr_shared_style.css.js';
import 'chrome://resources/cr_elements/cr_shared_vars.css.js';
import './shared_style.css.js';
import './strings.m.js';

import {assert} from 'chrome://resources/js/assert.js';
import {isRTL} from 'chrome://resources/js/util.js';
import {microTask, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {changeFolderOpen, selectFolder} from './actions.js';
import {BookmarksCommandManagerElement} from './command_manager.js';
import {FOLDER_OPEN_BY_DEFAULT_DEPTH, MenuSource, ROOT_NODE_ID} from './constants.js';
import {getTemplate} from './folder_node.html.js';
import {StoreClientMixin} from './store_client_mixin.js';
import type {BookmarkNode} from './types.js';
import {hasChildFolders, isShowingSearch} from './util.js';

const BookmarksFolderNodeElementBase = StoreClientMixin(PolymerElement);

export interface BookmarksFolderNodeElement {
  $: {
    container: HTMLElement,
    descendants: HTMLElement,
  };
}

export class BookmarksFolderNodeElement extends BookmarksFolderNodeElementBase {
  static get is() {
    return 'bookmarks-folder-node';
  }

  static get template() {
    return getTemplate();
  }

  static get properties() {
    return {
      itemId: {
        type: String,
        observer: 'updateFromStore',
      },

      depth: {
        type: Number,
        observer: 'depthChanged_',
      },

      isOpen: {
        type: Boolean,
        computed: 'computeIsOpen_(openState_, depth)',
      },

      item_: Object,

      openState_: Boolean,

      selectedFolder_: String,

      searchActive_: Boolean,

      isSelectedFolder_: {
        type: Boolean,
        reflectToAttribute: true,
        computed: 'computeIsSelected_(itemId, selectedFolder_, searchActive_)',
      },

      hasChildFolder_: {
        type: Boolean,
        computed: 'computeHasChildFolder_(item_.children)',
      },
    };
  }

  depth: number;
  isOpen: boolean;
  itemId: string;
  private item_: BookmarkNode;
  private openState_: boolean;
  private selectedFolder_: string;
  private searchActive_: boolean;
  private isSelectedFolder_: boolean = false;
  private hasChildFolder_: boolean;

  static get observers() {
    return [
      'updateAriaExpanded_(hasChildFolder_, isOpen)',
      'scrollIntoViewIfNeeded_(isSelectedFolder_)',
    ];
  }

  override ready() {
    super.ready();

    this.addEventListener('keydown', e => this.onKeydown_(e));
  }

  /** @override */
  override connectedCallback() {
    super.connectedCallback();
    this.watch('item_', state => {
      return state.nodes[this.itemId];
    });
    this.watch('openState_', state => {
      return state.folderOpenState.has(this.itemId) ?
          state.folderOpenState.get(this.itemId) :
          null;
    });
    this.watch('selectedFolder_', state => state.selectedFolder);
    this.watch('searchActive_', state => {
      return isShowingSearch(state);
    });

    this.updateFromStore();
  }

  private getContainerClass_(isSelectedFolder: boolean): string {
    return isSelectedFolder ? 'selected' : '';
  }

  getFocusTarget(): HTMLElement {
    return this.$.container;
  }

  getDropTarget(): HTMLElement {
    return this.$.container;
  }

  private onKeydown_(e: KeyboardEvent) {
    let yDirection = 0;
    let xDirection = 0;
    let handled = true;
    if (e.key === 'ArrowUp') {
      yDirection = -1;
    } else if (e.key === 'ArrowDown') {
      yDirection = 1;
    } else if (e.key === 'ArrowLeft') {
      xDirection = -1;
    } else if (e.key === 'ArrowRight') {
      xDirection = 1;
    } else if (e.key === ' ') {
      this.selectFolder_();
    } else {
      handled = false;
    }

    if (isRTL()) {
      xDirection *= -1;
    }

    this.changeKeyboardSelection_(
        xDirection, yDirection, this.shadowRoot!.activeElement);

    if (!handled) {
      handled = BookmarksCommandManagerElement.getInstance().handleKeyEvent(
          e, new Set([this.itemId]));
    }

    if (!handled) {
      return;
    }

    e.preventDefault();
    e.stopPropagation();
  }

  private changeKeyboardSelection_(
      xDirection: number, yDirection: number, currentFocus: Element|null) {
    let newFocusFolderNode = null;
    const isChildFolderNodeFocused = currentFocus &&
        (currentFocus as HTMLElement)!.tagName === 'BOOKMARKS-FOLDER-NODE';

    if (xDirection === 1) {
      // The right arrow opens a folder if closed and goes to the first child
      // otherwise.
      if (this.hasChildFolder_) {
        if (!this.isOpen) {
          this.dispatch(changeFolderOpen(this.item_.id, true));
        } else {
          yDirection = 1;
        }
      }
    } else if (xDirection === -1) {
      // The left arrow closes a folder if open and goes to the parent
      // otherwise.
      if (this.hasChildFolder_ && this.isOpen) {
        this.dispatch(changeFolderOpen(this.item_.id, false));
      } else {
        const parentFolderNode = this.getParentFolderNode();
        if (parentFolderNode!.itemId !== ROOT_NODE_ID) {
          parentFolderNode!.getFocusTarget().focus();
        }
      }
    }

    if (!yDirection) {
      return;
    }

    // The current node's successor is its first child when open.
    if (!isChildFolderNodeFocused && yDirection === 1 && this.isOpen) {
      const children = this.getChildFolderNodes_();
      if (children.length) {
        newFocusFolderNode = children[0];
      }
    }

    if (isChildFolderNodeFocused) {
      // Get the next child folder node if a child is focused.
      if (!newFocusFolderNode) {
        newFocusFolderNode = this.getNextChild(
            yDirection === -1, (currentFocus! as BookmarksFolderNodeElement));
      }

      // The first child's predecessor is this node.
      if (!newFocusFolderNode && yDirection === -1) {
        newFocusFolderNode = this;
      }
    }

    // If there is no newly focused node, allow the parent to handle the change.
    if (!newFocusFolderNode) {
      if (this.itemId !== ROOT_NODE_ID) {
        this.getParentFolderNode()!.changeKeyboardSelection_(
            0, yDirection, this);
      }

      return;
    }

    // The root node is not navigable.
    if (newFocusFolderNode.itemId !== ROOT_NODE_ID) {
      newFocusFolderNode.getFocusTarget().focus();
    }
  }

  /**
   * Returns the next or previous visible bookmark node relative to |child|.
   */
  getNextChild(reverse: boolean, child: BookmarksFolderNodeElement):
      BookmarksFolderNodeElement|null {
    let newFocus = null;
    const children = this.getChildFolderNodes_();

    const index = children.indexOf(child);
    assert(index !== -1);
    if (reverse) {
      // A child node's predecessor is either the previous child's last visible
      // descendant, or this node, which is its immediate parent.
      newFocus =
          index === 0 ? null : children[index - 1]!.getLastVisibleDescendant();
    } else if (index < children.length - 1) {
      // A successor to a child is the next child.
      newFocus = children[index + 1]!;
    }

    return newFocus;
  }

  /**
   * Returns the immediate parent folder node, or null if there is none.
   */
  getParentFolderNode(): BookmarksFolderNodeElement|null {
    let parentFolderNode = this.parentNode;
    while (parentFolderNode &&
           (parentFolderNode as HTMLElement).tagName !==
               'BOOKMARKS-FOLDER-NODE') {
      parentFolderNode =
          parentFolderNode.parentNode || (parentFolderNode as ShadowRoot).host;
    }
    return (parentFolderNode as BookmarksFolderNodeElement) || null;
  }

  getLastVisibleDescendant(): BookmarksFolderNodeElement {
    const children = this.getChildFolderNodes_();
    if (!this.isOpen || children.length === 0) {
      return this;
    }

    return children.pop()!.getLastVisibleDescendant();
  }

  private selectFolder_() {
    if (!this.isSelectedFolder_) {
      this.dispatch(selectFolder(this.itemId, this.getState().nodes));
    }
  }

  private onContextMenu_(e: MouseEvent) {
    e.preventDefault();
    this.selectFolder_();
    BookmarksCommandManagerElement.getInstance().openCommandMenuAtPosition(
        e.clientX, e.clientY, MenuSource.TREE, new Set([this.itemId]));
  }

  private getChildFolderNodes_(): BookmarksFolderNodeElement[] {
    return Array.from(this.shadowRoot!.querySelectorAll(
        'bookmarks-folder-node'));
  }

  /**
   * Toggles whether the folder is open.
   */
  private toggleFolder_(e: Event) {
    this.dispatch(changeFolderOpen(this.itemId, !this.isOpen));
    e.stopPropagation();
  }

  private preventDefault_(e: Event) {
    e.preventDefault();
  }

  private computeIsSelected_(
      itemId: string, selectedFolder: string, searchActive: boolean): boolean {
    return itemId === selectedFolder && !searchActive;
  }

  private computeHasChildFolder_(): boolean {
    return hasChildFolders(this.itemId, this.getState().nodes);
  }

  private depthChanged_() {
    this.style.setProperty('--node-depth', String(this.depth));
    if (this.depth === -1) {
      this.$.descendants.removeAttribute('role');
    }
  }

  private getChildDepth_(): number {
    return this.depth + 1;
  }

  private isFolder_(itemId: string): boolean {
    return !this.getState().nodes[itemId]!.url;
  }

  private isRootFolder_(): boolean {
    return this.itemId === ROOT_NODE_ID;
  }

  private getTabIndex_(): string {
    // This returns a tab index of 0 for the cached selected folder when the
    // search is active, even though this node is not technically selected. This
    // allows the sidebar to be focusable during a search.
    return this.selectedFolder_ === this.itemId ? '0' : '-1';
  }

  /**
   * Sets the 'aria-expanded' accessibility on nodes which need it. Note that
   * aria-expanded="false" is different to having the attribute be undefined.
   */
  private updateAriaExpanded_(hasChildFolder: boolean, isOpen: boolean) {
    if (hasChildFolder) {
      this.getFocusTarget().setAttribute('aria-expanded', String(isOpen));
    } else {
      this.getFocusTarget().removeAttribute('aria-expanded');
    }
  }

  /**
   * Scrolls the folder node into view when the folder is selected.
   */
  private scrollIntoViewIfNeeded_() {
    if (!this.isSelectedFolder_) {
      return;
    }

    microTask.run(() => this.$.container.scrollIntoViewIfNeeded());
  }

  private computeIsOpen_(openState: boolean|null, depth: number): boolean {
    return openState != null ? openState :
                               depth <= FOLDER_OPEN_BY_DEFAULT_DEPTH;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'bookmarks-folder-node': BookmarksFolderNodeElement;
  }
}

customElements.define(
    BookmarksFolderNodeElement.is, BookmarksFolderNodeElement);