chromium/ui/file_manager/file_manager/widgets/xf_cloud_panel.ts

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

/**
 * @fileoverview xf-cloud-panel element.
 */

import type {CrActionMenuElement} from 'chrome://resources/ash/common/cr_elements/cr_action_menu/cr_action_menu.js';

import {getCurrentLocaleOrDefault, secondsToRemainingTimeString, str, strf} from '../common/js/translations.js';
import {ICON_TYPES} from '../foreground/js/constants.js';

import {css, customElement, html, property, query, XfBase} from './xf_base.js';

/**
 * These type indicate static states that the cloud panel can enter. If one of
 * these is supplied, `items` and `percentage` is ignored.
 */
export enum CloudPanelType {
  OFFLINE = 'offline',
  BATTERY_SAVER = 'battery_saver',
  NOT_ENOUGH_SPACE = 'not_enough_space',
  METERED_NETWORK = 'metered_network',
}

/**
 * The `<xf-cloud-panel>` represents the current state that the Drive bulk
 * pinning process is currently in. When files are being pinned and downloaded,
 * the `items` and `progress` attributes are used to signify that the panel is
 * in progress. The `type` attribute can be used with `not_enough_space`,
 * `offline`, and `battery_saver` to signify possible error or paused states.
 */
@customElement('xf-cloud-panel')
export class XfCloudPanel extends XfBase {
  /**
   * The number of items currently syncing.
   */
  @property({type: Number, reflect: true, attribute: true}) items?: number;

  /**
   * The percentage that should be represented in the progress bar, this also
   * ensures the value is a valid value within the range [0, 100].
   */
  @property({
    type: Number,
    reflect: true,
    converter: {
      fromAttribute:
          (value: string) => {
            const percentage = parseInt(value, 10);
            return percentage >= 0 && percentage <= 100 ? percentage : null;
          },
      toAttribute: (value: number) => String(value),
    },
  })
  percentage?: number;

  /**
   * Attempts to map the supplied `type` attribute to an available value.
   */
  @property({
    type: CloudPanelType,
    reflect: true,
    converter: {
      fromAttribute:
          (value: string) => {
            if (!value) {
              return null;
            }
            if (value.toUpperCase() in CloudPanelType) {
              return value as CloudPanelType;
            }
            console.warn(`Failed to convert ${value} to CloudPanelType`);
            return null;
          },
      toAttribute: (key: keyof CloudPanelType) => key,
    },
  })
  type?: CloudPanelType;

  @property({
    type: Number,
    reflect: true,
    converter: {
      fromAttribute:
          (value: string) => {
            const seconds = parseInt(value, 10);
            return seconds >= 0 ? seconds : null;
          },
      toAttribute: (value: number) => String(value),
    },
  })
  seconds?: number;

  /**
   * The cloud panel uses the `CrActionMenu` to provide the dialog behaviour and
   * the overlay logic.
   */
  @query('cr-action-menu') private $panel_?: CrActionMenuElement;

  /**
   * Provide a number formatter that matches the users locale.
   */
  private numberFormatter_ = new Intl.NumberFormat(getCurrentLocaleOrDefault());

  static get events() {
    return {
      DRIVE_SETTINGS_CLICKED: 'drive_settings_clicked',
      PANEL_CLOSED: 'panel_closed',
    } as const;
  }

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

  /**
   * Returns true if the dialog is open, false otherwise.
   */
  get open() {
    return this.$panel_?.open || false;
  }

  /**
   * Show the element relative to the cloud icon that was clicked.
   */
  showAt(el: HTMLElement) {
    this.$panel_!.showAt(el, {top: el.offsetTop + el.offsetHeight + 20});
  }

  /**
   * Close the panel.
   */
  close() {
    if (this.open) {
      this.$panel_!.close();
    }
  }

  /**
   * Refires the close event to ensure it's a known `XfCloudPanel` event to
   * subscribe to.
   */
  override async connectedCallback() {
    super.connectedCallback();
    await this.updateComplete;
    this.$panel_!.addEventListener('close', () => {
      this.dispatchEvent(new CustomEvent(XfCloudPanel.events.PANEL_CLOSED, {
        bubbles: true,
        composed: true,
      }));
    });
  }

  /**
   * Handles click events for the Google Drive settings button. This emits the
   * event to be handled by the container.
   */
  private onSettingsClicked_(event: MouseEvent|KeyboardEvent) {
    event.stopImmediatePropagation();
    event.preventDefault();

    if ((event as KeyboardEvent).repeat) {
      return;
    }

    this.dispatchEvent(
        new CustomEvent(XfCloudPanel.events.DRIVE_SETTINGS_CLICKED, {
          bubbles: true,
          composed: true,
        }));
  }

  override render() {
    return html`<cr-action-menu>
      <div class="body">
        <div class="static progress" id="progress-preparing">
          <files-spinner></files-spinner>
          ${str('DRIVE_PREPARING_TO_SYNC')}
        </div>
        <div id="progress-state">
          <div class="progress">${
        this.items && this.items > 1 ?
            strf(
                'DRIVE_MULTIPLE_FILES_SYNCING',
                this.numberFormatter_.format(this.items)) :
            str('DRIVE_SINGLE_FILE_SYNCING')}</div>
          <progress
              class="progress-bar"
              max="100"
              value="${this.percentage}">
            ${this.percentage}%
          </progress>
          <div class="progress-description">
          ${
        this.seconds && this.seconds > 0 ?
            secondsToRemainingTimeString(this.seconds) :
            str('DRIVE_BULK_PINNING_CALCULATING')}
          </div>
        </div>
        <div class="static" id="progress-finished">
          <xf-icon type="${ICON_TYPES.CLOUD}" size="large"></xf-icon>
          <div class="status-description">
            ${str('BULK_PINNING_FILE_SYNC_ON')}
          </div>
        </div>
        <div class="static" id="progress-offline">
        <xf-icon type="${
        ICON_TYPES.BULK_PINNING_OFFLINE}" size="large"></xf-icon>
          <div class="status-description">
            ${str('DRIVE_BULK_PINNING_OFFLINE')}
          </div>
        </div>
        <div class="static" id="progress-battery-saver">
        <xf-icon type="${
        ICON_TYPES.BULK_PINNING_BATTERY_SAVER}" size="large"></xf-icon>
          <div class="status-description">
            ${str('DRIVE_BULK_PINNING_BATTERY_SAVER')}
          </div>
        </div>
        <div class="static" id="progress-not-enough-space">
        <xf-icon type="${ICON_TYPES.ERROR_BANNER}" size="large"></xf-icon>
          <div class="status-description">
            ${str('DRIVE_BULK_PINNING_NOT_ENOUGH_SPACE')}
          </div>
        </div>
        <div class="static" id="progress-metered-network">
          <xf-icon type="${ICON_TYPES.CLOUD}" size="large"></xf-icon>
          <div class="status-description">
            ${str('DRIVE_BULK_PINNING_METERED_NETWORK')}
          </div>
        </div>
        <div class="divider"></div>
        <button class="action" @click=${this.onSettingsClicked_}>${
        str('GOOGLE_DRIVE_SETTINGS_LINK')}</button>
      </div>
    </cr-action-menu>`;
  }
}

function getCSS() {
  return css`
    cr-action-menu {
      --cr-menu-border-radius: 20px;
    }

    :host {
      position: absolute;
      right: 0px;
      top: 50px;
      z-index: 600;
    }

    :host(:not([items][percentage])) #progress-state,
    :host([percentage="100"]) #progress-state,
    :host([type]) #progress-state {
      display: none;
    }

    :host(:not([items][percentage="100"])) #progress-finished,
    :host([type]) #progress-finished {
      display: none;
    }

    :host([percentage][items]) #progress-preparing,
    :host([type]) #progress-preparing {
      display: none;
    }

    :host(:not([type="offline"])) #progress-offline {
      display: none;
    }

    :host(:not([type="battery_saver"])) #progress-battery-saver {
      display: none;
    }

    :host(:not([type="not_enough_space"])) #progress-not-enough-space {
      display: none;
    }

    :host(:not([type="metered_network"])) #progress-metered-network {
      display: none;
    }

    .body {
      background-color: var(--cros-sys-base_elevated);
      display: flex;
      flex-direction: column;
      margin: -8px 0;
      width: 320px;
    }

    .static {
      align-items: center;
      display: flex;
      flex-direction: column;
    }

    xf-icon {
      padding: 27px 0px 8px;
    }

    xf-icon[type="bulk_pinning_done"] {
      --xf-icon-color: var(--cros-sys-positive);
    }

    xf-icon[type="bulk_pinning_offline"] {
      --xf-icon-color: var(--cros-sys-secondary);
    }

    xf-icon[type="bulk_pinning_battery_saver"] {
      --xf-icon-color: var(--cros-sys-secondary);
    }

    xf-icon[type="error_banner"] {
      --xf-icon-color: var(--cros-sys-error);
    }

    .status-description {
      color: var(--cros-sys-on_surface_variant);
      font: var(--cros-annotation-1-font);
      line-height: 20px;
      padding: 0px 16px 20px;
      text-align: center;
    }

    .progress {
      color: var(--cros-sys-on_surface);
      font: var(--cros-button-2-font);
      line-height: 20px;
      margin-inline: 16px;
      padding-top: 20px;
    }

    .progress-description {
      color: var(--cros-sys-on_surface_variant);
      font: var(--cros-annotation-1-font);
      padding-bottom: 20px;
      padding-inline: 16px;
    }

    .progress-bar {
      border-radius: 10px;
      height: 4px;
      margin: 8px 0 8px;
      margin-inline: 16px;
      width: calc(100% - 32px);
    }

    #progress-preparing {
      flex-direction: row;
      padding-bottom: 20px;
    }

    #progress-preparing files-spinner {
      height: 20px;
      margin: 0;
      margin-inline-end: 8px;
      width: 20px;
    }

    progress::-webkit-progress-bar {
      background-color: var(--cros-sys-highlight_shape);
      border-radius: 10px;
    }

    progress.progress-bar::-webkit-progress-value {
      background-color: var(--cros-sys-primary);
      border-radius: 10px;
    }

    .divider {
      background: var(--cros-sys-separator);
      height: 1px;
      width: 100%;
    }

    button.action {
      background-color: var(--cros-sys-base_elevated);
      border: 0;
      font: var(--cros-button-2-font);
      height: 36px;
      margin-bottom: 8px;
      margin-top: 8px;
      padding-inline: 16px;
      text-align: left;
    }

    :host-context([dir='rtl']) button.action {
      text-align: right;
    }

    .action {
      width: 100%;
    }

    .action:hover {
      background: var(--cros-sys-hover_on_subtle);
    }
  `;
}

export type CloudPanelSettingsClickEvent = CustomEvent;

export type CloudPanelCloseEvent = CustomEvent;

declare global {
  interface HTMLElementEventMap {
    [XfCloudPanel.events.DRIVE_SETTINGS_CLICKED]: CloudPanelSettingsClickEvent;
    [XfCloudPanel.events.PANEL_CLOSED]: CloudPanelCloseEvent;
  }

  interface HTMLElementTagNameMap {
    'xf-cloud-panel': XfCloudPanel;
  }
}