chromium/chrome/browser/resources/chromeos/manage_mirrorsync/components/folder_selector.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 {getTemplate} from './folder_selector.html.js';

/**
 * Retrieves the parent folder path of the supplied `folderPath`. Useful to
 * identify the list container to place the folder element.
 */
function getParentPath(folderPath: string): string {
  const folderParts = folderPath.split('/');
  const parentPath = folderParts.slice(0, folderParts.length - 1).join('/');
  if (parentPath.length === 0) {
    return '/';
  }
  return parentPath;
}

/**
 * Retrieves the folder name as the final element of an absolute path.
 */
function getFolderName(folderPath: string): string {
  const folderParts = folderPath.split('/');
  return folderParts[folderParts.length - 1]!;
}

/**
 * Helper function to convert a supplied folder path to it's querySelector
 * variant using the data-full-path key and escaping double quotes.
 */
function selectorFromPath(folderPath: string): string {
  return `input[data-full-path="${folderPath.replace(/"/g, '\\"')}"]`;
}

/**
 * FolderSelector presents a folder hierarchy of checkboxes representing the
 * underlying folder structure. The items are lazily loaded as required.
 */
export class FolderSelector extends HTMLElement {
  /* The <template> fragment used to create new elements. */
  private folderSelectorTemplate: HTMLTemplateElement;

  /* A Set of currently selected folders. */
  private selectedFolders: Set<string> = new Set();

  constructor() {
    super();
    this.attachShadow({mode: 'open'})
        .appendChild(getTemplate().content.cloneNode(true));

    this.folderSelectorTemplate =
        this.shadowRoot!.getElementById('folder-selector-template') as
        HTMLTemplateElement;
  }

  /**
   * Once the <folder-selector> component has been connected to the DOM, this
   * lifecycle callback is invoked.
   */
  connectedCallback() {
    // Register event listeners on the root list element.
    const li = this.shadowRoot?.querySelector('#select-folders > ul > li') as
        HTMLLIElement;
    li.addEventListener('click', (event) => this.onPathExpanded(event, '/'));

    const selector = selectorFromPath('/');
    const input = this.shadowRoot?.querySelector(selector) as HTMLInputElement;
    input.addEventListener('click', event => {
      // The <li> enclosing the <input> also has an event listener so don't
      // propagate once the click has been received.
      event.stopPropagation();
      this.onPathSelected(event, '/');
    });
  }

  /**
   * Add an array of paths to the DOM. These paths must all share the same
   * parent.
   */
  async addChildFolders(folderPaths: string[]) {
    const parentElements: Map<string, HTMLInputElement> = new Map();
    let parentSelected: boolean = false;
    /**
     * Get the parent container and in the process cache the parent element. All
     * folders coming in via addChildFolders share the same parent.
     * @param folderPath
     */
    const getParentContainer = (folderPath: string) => {
      const parentPath = getParentPath(folderPath);
      if (!parentElements.has(parentPath)) {
        const parentSelector = selectorFromPath(parentPath);
        const parentElement =
            this.shadowRoot!.querySelector<HTMLInputElement>(parentSelector)!;
        parentElements.set(parentPath, parentElement);
        parentSelected = parentElement.checked;
      }
      parentSelected = parentElements.get(parentPath)!.checked;
      return parentElements.get(parentPath)!.parentElement!.nextElementSibling!;
    };

    for (const path of folderPaths) {
      if (this.shadowRoot?.querySelector(selectorFromPath(path))) {
        continue;
      }
      const ulContainer = getParentContainer(path);
      const newElement = this.createNewFolderSelection(path, parentSelected);
      ulContainer?.appendChild(newElement);
    }
  }

  /**
   * Returns the list of paths that are currently selected.
   */
  get selectedPaths() {
    return Array.from(this.selectedFolders.values());
  }

  /**
   * Event listener for when a checkbox for a path is selected. We want to
   * update all descendants to be disabled and checked and ensure the selected
   * path is being kept track of.
   */
  private onPathSelected(event: Event, path: string) {
    const input = (event.currentTarget as HTMLInputElement);
    const {checked} = input;
    if (checked) {
      this.selectedFolders.add(path);
    } else {
      this.selectedFolders.delete(path);
    }
    const children =
        input.parentElement!.nextElementSibling!.querySelectorAll('input');
    for (const child of children) {
      child.toggleAttribute('disabled', checked);
      child.toggleAttribute('checked', checked);
    }
  }

  /**
   * Event listener for when a path has been clicked (excluding the checkbox).
   * Dispatches an event to enable the <manage-mirrorsync> to fetch the children
   * folders.
   */
  private onPathExpanded(event: Event, path: string) {
    const li = (event.currentTarget as HTMLElement) as HTMLLIElement;
    li.toggleAttribute('expanded');
    if (li.hasAttribute('retrieved')) {
      return;
    }
    this.dispatchEvent(new CustomEvent(
        FOLDER_EXPANDED, {bubbles: true, composed: true, detail: path}));
    li.toggleAttribute('retrieved', true);
  }

  /**
   * Creates a new folder selection and assigns the requisite event listeners.
   * Uses the shadowRoot <template> fragment that contains a minimal
   * representation and builds on top of that.
   */
  private createNewFolderSelection(folderPath: string, selected: boolean):
      HTMLElement {
    const newFolderTemplate =
        this.folderSelectorTemplate.content.cloneNode(true) as HTMLElement;
    const textNode = document.createTextNode(getFolderName(folderPath));

    const li = newFolderTemplate.querySelector('li')!;
    li.appendChild(textNode);
    li.addEventListener(
        'click', (event) => this.onPathExpanded(event, folderPath));

    const input = newFolderTemplate.querySelector('input[name="folders"]')!;
    input.setAttribute('data-full-path', folderPath);
    input.toggleAttribute('disabled', selected);
    input.toggleAttribute('checked', selected);

    // TODO(b/237066325): Add one event listener to the <folder-selector> and
    // switch on the element clicked to identify whether it is expanded or
    // selected to avoid too many event listeners.
    input.addEventListener('click', event => {
      event.stopPropagation();
      this.onPathSelected(event, folderPath);
    });

    return newFolderTemplate;
  }
}

/**
 * The available events that occur from this web component.
 */
export const FOLDER_EXPANDED = 'folder_selected';

/**
 * Custom event alias, the event.detail key has the folder path that indicates
 * the folder to expand.
 */
export type FolderExpandedEvent = CustomEvent<string>;

declare global {
  interface HTMLElementEventMap {
    [FOLDER_EXPANDED]: FolderExpandedEvent;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'folder-selector': FolderSelector;
  }
}

customElements.define('folder-selector', FolderSelector);