chromium/ui/file_manager/file_manager/widgets/xf_conflict_dialog.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 type {CrButtonElement} from 'chrome://resources/ash/common/cr_elements/cr_button/cr_button.js';
import type {CrCheckboxElement} from 'chrome://resources/ash/common/cr_elements/cr_checkbox/cr_checkbox.js';
import type {CrDialogElement} from 'chrome://resources/ash/common/cr_elements/cr_dialog/cr_dialog.js';

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

import {getTemplate} from './xf_conflict_dialog.html.js';

/**
 * Files Conflict Dialog: if the target file of a copy/move operation exists,
 * the conflict dialog can be used to ask the user what to do in that case.
 *
 * The user can choose to 'cancel' the copy/move operation by cancelling the
 * dialog. Otherwise, the user can choose to 'replace' the file or 'keepboth'
 * to keep the file.
 */
export class XfConflictDialog extends HTMLElement {
  /**
   * Mutex used to serialize conflict modal dialog use.
   */
  private mutex_ = new AsyncQueue();

  /**
   * Modal dialog element.
   */
  private dialog_: CrDialogElement;

  /**
   * Either 'keepboth' or 'replace' on dialog success, or an empty string if
   * the dialog was cancelled.
   */
  private action_: string = '';

  /**
   * Returns dialog success result using the Promise.resolve method.
   */
  private resolve_: (result: ConflictResult) => void;

  /**
   * Returns dialog cancelled error using the Promise.reject method.
   */
  private reject_: (error: Error) => void;

  /**
   * Construct.
   */
  constructor() {
    super();

    // Create element content.
    const template = document.createElement('template');
    template.innerHTML = getTemplate() as unknown as string;
    const fragment = template.content.cloneNode(true);
    this.attachShadow({mode: 'open'}).appendChild(fragment);

    this.dialog_ = this.getDialogElement();
    this.resolve_ = console.log;
    this.reject_ = console.log;
  }

  /**
   * DOM connected callback.
   */
  connectedCallback() {
    this.dialog_.addEventListener('close', this.closed_.bind(this));

    this.getCheckboxElement().onchange = this.checked_.bind(this);
    this.getCancelButton().onclick = this.cancel_.bind(this);
    this.getKeepbothButton().onclick = this.keepboth_.bind(this);
    this.getReplaceButton().onclick = this.replace_.bind(this);
  }

  /**
   * Open the modal dialog to ask the user to resolve a conflict for the given
   * |filename|. The default parameters after |filename| are as follows:
   *
   * Set |checkbox| true to display the 'Apply to all' checkbox in the dialog,
   *   and should be set true if there are potentially, multiple file names in
   *   a copy or move operation that conflict. The default is false.
   *
   * Set |directory| true if the |filename| is a directory (aka a folder). The
   *   default is false.
   */
  async show(
      filename: string, checkbox: boolean = false,
      directory: boolean = false): Promise<ConflictResult> {
    const unlock = await this.mutex_.lock();
    try {
      return await new Promise<ConflictResult>((resolve, reject) => {
        this.resolve_ = resolve;
        this.reject_ = reject;
        this.showModal_(filename, checkbox, directory);
      });
    } finally {
      unlock();
    }
  }

  /**
   * Resets the dialog for the given |filename| |checkbox| and |folder| values
   * and then shows the modal dialog.
   */
  private showModal_(filename: string, checkbox: boolean, folder: boolean) {
    const message =  // 'A folder named ...' or 'A file named ...'
        folder ? 'CONFLICT_DIALOG_FOLDER_MESSAGE' : 'CONFLICT_DIALOG_MESSAGE';
    this.getMessageElement().innerText = strf(message, filename);

    const applyToAll = this.getCheckboxElement();
    applyToAll.hidden = !checkbox;
    applyToAll.checked = false;

    this.action_ = '';
    this.checked_();
    this.dialog_.showModal();
    this.setFocus_();
  }

  /**
   * The conflict dialog has no title. Remove the <cr-dialog> title child that
   * would focus, remove its <dialog> aria-labelledby and aria-describedby, so
   * ARIA announces the #message (that is the initial focus) once.
   *
   * Per https://w3c.github.io/aria-practices/#dialog_roles_states_props, adds
   * aria-modal='true' meaning all content outside the <dialog> is inert.
   */
  private setFocus_() {
    this.dialog_.shadowRoot!.querySelector('#title')?.remove();

    const element = this.getHtmlDialogElement();
    element.setAttribute('aria-modal', 'true');
    element.removeAttribute('aria-labelledby');
    element.removeAttribute('aria-describedby');

    this.getMessageElement().focus();
  }

  /**
   * Returns 'dialog' element.
   */
  getDialogElement(): CrDialogElement {
    return this.shadowRoot!.querySelector('#conflict-dialog')!;
  }

  /**
   * Returns 'dialog' <dialog> element.
   */
  getHtmlDialogElement(): HTMLDialogElement {
    return this.dialog_.getNative()!;
  }

  /**
   * Returns 'message' element.
   */
  getMessageElement(): HTMLElement {
    return this.dialog_.querySelector('#message')!;
  }

  /**
   * Returns 'Apply to all' checkbox element.
   */
  getCheckboxElement(): CrCheckboxElement {
    return this.shadowRoot!.querySelector('#checkbox')!;
  }

  /**
   * 'Apply to all' checkbox value changed.
   */
  private checked_() {
    const checked = this.getCheckboxElement().checked;

    if (checked) {
      this.getKeepbothButton().innerText = str('CONFLICT_DIALOG_KEEP_ALL');
      this.getReplaceButton().innerText = str('CONFLICT_DIALOG_REPLACE_ALL');
    } else {
      this.getKeepbothButton().innerText = str('CONFLICT_DIALOG_KEEP_BOTH');
      this.getReplaceButton().innerText = str('CONFLICT_DIALOG_REPLACE');
    }

    this.getCheckboxElement().focus();
    this.toggleAttribute('checked', checked);
  }

  /**
   * Returns 'cancel' button element.
   */
  getCancelButton(): CrButtonElement {
    return this.shadowRoot!.querySelector('#cancel')!;
  }

  /**
   * Dialog was cancelled.
   */
  private cancel_() {
    this.action_ = '';
    this.dialog_.close();
  }

  /**
   * Returns 'keepboth' button element.
   */
  getKeepbothButton(): CrButtonElement {
    return this.shadowRoot!.querySelector('#keepboth')!;
  }

  /**
   * Dialog 'keepboth' button was clicked.
   */
  private keepboth_() {
    this.action_ = ConflictResolveType.KEEPBOTH;
    this.dialog_.close();
  }

  /*
   * Returns 'replace' button element.
   */
  getReplaceButton(): CrButtonElement {
    return this.shadowRoot!.querySelector('#replace')!;
  }

  /**
   * Dialog 'replace' button was clicked.
   */
  private replace_() {
    this.action_ = ConflictResolveType.REPLACE;
    this.dialog_.close();
  }

  /*
   * Triggered by the modal dialog close(): rejects the Promise if the dialog
   * was cancelled or resolves it with the dialog result.
   */
  private closed_() {
    if (!this.action_) {
      this.reject_(new Error('dialog cancelled'));
      return;
    }

    const applyToAll = this.getCheckboxElement().checked;
    this.resolve_({
      resolve: this.action_,  // Either 'keepboth' or 'replace'.
      checked: applyToAll,    // True or False.
    } as ConflictResult);
  }
}

export interface ConflictResult {
  resolve: ConflictResolveType;
  checked: boolean;
}

export enum ConflictResolveType {
  KEEPBOTH = 'keepboth',
  REPLACE = 'replace',
}

declare global {
  interface HTMLElementTagNameMap {
    'xf-conflict-dialog': XfConflictDialog;
  }
}

customElements.define('xf-conflict-dialog', XfConflictDialog);