chromium/ui/file_manager/file_manager/foreground/elements/xf_display_panel.ts

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

import {getPluralString, str, strf} from '../../common/js/translations.js';

import type {PanelButton} from './xf_button.js';
import {getTemplate} from './xf_display_panel.html.js';
import type {PanelItem} from './xf_panel_item.js';
import {PanelType} from './xf_panel_item.js';

/**
 * A panel to display a collection of PanelItem.
 */
export class DisplayPanel extends HTMLElement {
  static get is() {
    return 'xf-display-panel' as const;
  }

  /**
   * True if the panel is collapsed to summary view.
   */
  private collapsed_: boolean = true;

  /**
   * Collection of PanelItems hosted in this DisplayPanel.
   */
  private items_: PanelItem[] = [];
  private summary_: HTMLDivElement;
  private separator_: HTMLDivElement;
  private panels_: HTMLDivElement;

  private toggleSummaryBound_ = this.toggleSummary_.bind(this);

  constructor() {
    super();
    this.createElement_();
    this.summary_ = this.shadowRoot!.querySelector<HTMLDivElement>('#summary')!;
    this.separator_ =
        this.shadowRoot!.querySelector<HTMLDivElement>('#separator')!;
    this.panels_ = this.shadowRoot!.querySelector<HTMLDivElement>('#panels')!;
  }

  /**
   * Creates an instance of DisplayPanel, attaching the template clone.
   */
  private createElement_() {
    const template = document.createElement('template');
    template.innerHTML = getTemplate() as unknown as string;
    const fragment = template.content.cloneNode(true);
    this.attachShadow({mode: 'open'}).appendChild(fragment);
  }

  /**
   * We cannot set attributes in the constructor for custom elements when using
   * `createElement()`. Set attributes in the connected callback instead.
   */
  connectedCallback() {
    this.setAriaHidden_();
  }

  /**
   * Re-enable scrollbar visibility after expand/contract animation.
   */
  private panelExpandFinished_(_: Event) {
    this.panels_.classList.remove('expanding');
    this.panels_.classList.add('expandfinished');
  }

  /**
   * Hides the active panel items at end of collapse animation.
   */
  private panelCollapseFinished_(_: Event) {
    this.panels_.hidden = true;
    this.panels_.setAttribute('aria-hidden', 'true');
    this.panels_.classList.remove('expanding');
    this.panels_.classList.add('expandfinished');
  }

  /**
   * Set attributes and style for expanded summary panel.
   */
  setSummaryExpandedState(expandButton: PanelButton) {
    expandButton.setAttribute('data-category', 'collapse');
    expandButton.setAttribute('aria-label', str('FEEDBACK_COLLAPSE_LABEL'));
    expandButton.setAttribute('aria-expanded', 'true');
    this.panels_.hidden = false;
    this.separator_.hidden = false;
  }

  /**
   * Event handler to toggle the visible state of panel items.
   */
  private toggleSummary_(_: Event) {
    const summaryPanel =
        this.summary_.querySelector<PanelItem>('xf-panel-item')!;
    const expandButton =
        summaryPanel.shadowRoot!.querySelector<PanelButton>('#primary-action')!;
    if (this.collapsed_) {
      this.collapsed_ = false;
      this.setSummaryExpandedState(expandButton);
      this.panels_.addEventListener(
          'animationend', this.panelExpandFinished_.bind(this), {once: true});
      this.panels_.setAttribute('class', 'expanded expanding');
      summaryPanel.setAttribute('data-category', 'expanded');
    } else {
      this.collapsed_ = true;
      expandButton.setAttribute('data-category', 'expand');
      expandButton.setAttribute('aria-label', str('FEEDBACK_EXPAND_LABEL'));
      expandButton.setAttribute('aria-expanded', 'false');
      this.separator_.hidden = true;
      this.panels_.addEventListener(
          'animationend', this.panelCollapseFinished_.bind(this), {once: true});
      this.panels_.setAttribute('class', 'collapsed expanding');
      summaryPanel.setAttribute('data-category', 'collapsed');
    }
  }

  /**
   * Get an array of panel items that are connected to the DOM.
   */
  private connectedPanelItems_(): PanelItem[] {
    return this.items_.filter(item => item.isConnected);
  }

  /**
   * Update the summary panel item progress indicator.
   */
  async updateProgress() {
    let total = 0;

    if (this.items_.length === 0) {
      return;
    }
    let errors = 0;
    let warnings = 0;
    let progressCount = 0;
    const connectedPanels = this.connectedPanelItems_();
    for (const panel of connectedPanels) {
      // Only sum progress for attached progress panels.
      if (panel.panelType === PanelType.PROGRESS ||
          panel.panelType === PanelType.FORMAT_PROGRESS ||
          panel.panelType === PanelType.SYNC_PROGRESS) {
        total += Number(panel.progress);
        progressCount++;
      } else if (panel.panelType === PanelType.ERROR) {
        errors++;
      } else if (panel.panelType === PanelType.INFO) {
        warnings++;
      }
    }
    if (progressCount > 0) {
      total /= progressCount;
    }
    const summaryPanel =
        this.summary_.querySelector<PanelItem>('xf-panel-item');
    if (!summaryPanel) {
      return;
    }
    // Show either a progress indicator or a status indicator (success, warning,
    // error) if no operations are ongoing.
    if (progressCount > 0) {
      // Make sure we have a progress indicator on the summary panel.
      if (summaryPanel.indicator !== 'largeprogress') {
        summaryPanel.indicator = 'largeprogress';
      }
      summaryPanel.primaryText = strf('PERCENT_COMPLETE', total.toFixed(0));
      summaryPanel.progress = String(total);
      summaryPanel.setAttribute('count', String(progressCount));
      summaryPanel.errorMarkerVisibility = (errors > 0) ? 'visible' : 'hidden';
      return;
    }

    if (summaryPanel.indicator !== 'status') {
      // Make sure we have a status indicator on the summary panel.
      summaryPanel.indicator = 'status';
    }

    if (errors > 0 && warnings > 0) {
      // Both errors and warnings: show the error indicator, along with counts
      // of both.
      summaryPanel.status = 'failure';
      const errorMessage = await this.generateErrorMessage_(errors);
      const warningMessage = await this.generateWarningMessage_(errors);
      summaryPanel.primaryText = `${errorMessage} ${warningMessage}`;
      return;
    }

    if (errors > 0) {
      // Only errors, but no warnings.
      summaryPanel.status = 'failure';
      summaryPanel.primaryText = await this.generateErrorMessage_(errors);
      return;
    }

    if (warnings > 0) {
      // Only warnings, but no errors.
      summaryPanel.status = 'warning';
      summaryPanel.primaryText = await this.generateWarningMessage_(warnings);
      return;
    }

    // No errors or warnings.
    summaryPanel.status = 'success';
    summaryPanel.primaryText = strf('PERCENT_COMPLETE', 100);
  }

  /**
   * Update the summary panel.
   * @public
   */
  updateSummaryPanel() {
    const summaryHost = this.shadowRoot!.querySelector('#summary')!;
    let summaryPanel = summaryHost.querySelector<PanelItem>('#summary-panel');

    // Make the display panel available by tab if there are panels to
    // show and there's an aria-label for use by a screen reader.
    if (this.hasAttribute('aria-label')) {
      this.tabIndex = this.items_.length ? 0 : -1;
    }
    // Work out how many panel items are being shown.
    const count = this.connectedPanelItems_().length;
    // If there's only one panel item active, no need for summary.
    if (count <= 1 && summaryPanel) {
      const button = summaryPanel.primaryButton;
      if (button) {
        button.removeEventListener('click', this.toggleSummaryBound_);
      }
      // For transfer summary details.
      const textDiv = summaryPanel.textDiv;
      if (textDiv) {
        textDiv.removeEventListener('click', this.toggleSummaryBound_);
      }
      summaryPanel.remove();
      this.panels_.hidden = false;
      this.separator_.hidden = true;
      this.panels_.classList.remove('collapsed');
      return;
    }
    // Show summary panel if there are more than 1 panel items.
    if (count > 1 && !summaryPanel) {
      summaryPanel = document.createElement('xf-panel-item');
      summaryPanel.panelType = PanelType.SUMMARY;
      summaryPanel.id = 'summary-panel';
      summaryPanel.setAttribute('detailed-summary', '');
      const button = summaryPanel.primaryButton!;
      if (button) {
        button.addEventListener('click', this.toggleSummaryBound_);
      }
      const textDiv = summaryPanel.textDiv;
      if (textDiv) {
        textDiv.addEventListener('click', this.toggleSummaryBound_);
      }
      summaryHost.appendChild(summaryPanel);
      // Setup the panels based on expand/collapse state of the summary panel.
      if (this.collapsed_) {
        this.panels_.hidden = true;
        summaryPanel.setAttribute('data-category', 'collapsed');
      } else {
        this.setSummaryExpandedState(button);
        this.panels_.classList.add('expandfinished');
        summaryPanel.setAttribute('data-category', 'expanded');
      }
    }
    if (summaryPanel) {
      this.updateProgress();
    }
  }

  /**
   * Create a panel item suitable for attaching to our display panel.
   * @param id The identifier attached to this panel.
   */
  createPanelItem(id: string): PanelItem {
    const panel = document.createElement('xf-panel-item');
    panel.id = id;
    panel.updateProgress = this.updateProgress.bind(this);
    panel.updateSummaryPanel = this.updateSummaryPanel.bind(this);
    panel.setAttribute('indicator', 'progress');
    this.items_.push(panel);
    this.setAriaHidden_();
    this.setAttribute('detailed-panel', 'detailed-panel');
    return panel;
  }

  /**
   * Attach a panel item element inside our display panel.
   * @param panel The panel item to attach.
   */
  attachPanelItem(panel: PanelItem) {
    // Only attach the panel if it hasn't been removed.
    const index = this.items_.indexOf(panel);
    if (index === -1) {
      return;
    }

    // If it's already attached, nothing to do here.
    if (panel.isConnected) {
      return;
    }

    this.panels_.appendChild(panel);
    this.updateSummaryPanel();
    this.setAriaHidden_();
  }

  /**
   * Add a panel entry element inside our display panel.
   * @param id The identifier attached to this panel.
   */
  addPanelItem(id: string): PanelItem {
    const panel = this.createPanelItem(id);
    this.attachPanelItem(panel);
    return panel;
  }

  /**
   * Remove a panel from this display panel.
   * @param item The PanelItem to remove.
   * @public
   */
  removePanelItem(item: PanelItem) {
    const index = this.items_.indexOf(item);
    if (index === -1) {
      return;
    }
    item.remove();
    this.items_.splice(index, 1);
    this.setAriaHidden_();
    this.updateSummaryPanel();
  }

  /**
   * Set aria-hidden to false if there is no panel.
   */
  private setAriaHidden_() {
    const hasItems = this.connectedPanelItems_().length > 0;
    this.setAttribute('aria-hidden', String(!hasItems));
  }

  /**
   * Find a panel with given 'id'.
   */
  findPanelItemById(id: string): PanelItem|null {
    for (const item of this.items_) {
      if (item.getAttribute('id') === id) {
        return item;
      }
    }
    return null;
  }

  /**
   * Remove all panel items.
   */
  removeAllPanelItems(): void {
    for (const item of this.items_) {
      item.remove();
    }
    this.items_ = [];
    this.setAriaHidden_();
    this.updateSummaryPanel();
  }

  /**
   * Generates the summary panel title message based on the number of errors.
   * @param errors Number of error subpanels.
   * @return Title text.
   */
  private async generateErrorMessage_(errors: number): Promise<string> {
    if (errors <= 0) {
      console.warn(
          `generateWarningMessage_ expected errors > 0, but got ${errors}.`);
      return '';
    }
    return getPluralString('ERROR_PROGRESS_SUMMARY', errors);
  }

  /**
   * Generates the summary panel title message based on the number of warnings.
   * @param warnings Number of warning subpanels.
   * @return Title text.
   */
  private async generateWarningMessage_(warnings: number): Promise<string> {
    if (warnings <= 0) {
      console.warn(`generateWarningMessage_ expected warnings > 0, but got ${
          warnings}.`);
      return '';
    }
    return getPluralString('WARNING_PROGRESS_SUMMARY', warnings);
  }
}

declare global {
  interface HTMLElementTagNameMap {
    [DisplayPanel.is]: DisplayPanel;
  }
}

window.customElements.define(DisplayPanel.is, DisplayPanel);