chromium/ui/file_manager/file_manager/foreground/js/ui/dialog_footer.ts

// Copyright 2014 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/ash/common/cr_elements/cr_input/cr_input.js';

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

import {getKeyModifiers, queryRequiredElement} from '../../../common/js/dom_utils.js';
import {getFileTypeForName} from '../../../common/js/file_types_base.js';
import type {TypeList} from '../../../common/js/files_app_state.js';
import {str} from '../../../common/js/translations.js';
import {DialogType} from '../../../state/state.js';
import {FileListModel} from '../file_list_model.js';

/**
 * Obtains the label of OK button for the dialog type.
 * @param dialogType Dialog type.
 * @return OK button label.
 */
function getOkButtonLabel(dialogType: DialogType): string {
  switch (dialogType) {
    case DialogType.SELECT_UPLOAD_FOLDER:
      return str('UPLOAD_LABEL');

    case DialogType.SELECT_SAVEAS_FILE:
      return str('SAVE_LABEL');

    case DialogType.SELECT_FOLDER:
    case DialogType.SELECT_OPEN_FILE:
    case DialogType.SELECT_OPEN_MULTI_FILE:
    case DialogType.FULL_PAGE:
      return str('OPEN_LABEL');

    default:
      throw new Error('Unknown dialog type: ' + dialogType);
  }
}

/**
 * Footer shown when the Files app is opened as a file/folder selecting dialog.
 */
export class DialogFooter {
  /** OK button in the footer. */
  readonly okButton: HTMLButtonElement;

  /** OK button's label in the footer. */
  readonly okButtonLabel: HTMLSpanElement;

  /** Cancel button in the footer. */
  readonly cancelButton: HTMLButtonElement;

  /** New folder button in the footer. */
  readonly newFolderButton: HTMLButtonElement;

  /** File type selector in the footer. */
  readonly fileTypeSelector: HTMLElement;

  readonly fileTypeSelectorText: HTMLSpanElement;

  /**
   * @param dialogType The type of the dialog (folder select, save-as, etc.)
   * @param element The element that holds the dialog footer.
   * @param filenameInput Filename input element.
   */
  constructor(
      private readonly dialogType_: DialogType,
      public readonly element: Element, public filenameInput: CrInputElement) {
    this.okButton = this.element.querySelector<HTMLButtonElement>('.ok')!;
    this.okButtonLabel = this.okButton.querySelector<HTMLSpanElement>('span')!;
    this.cancelButton =
        this.element.querySelector<HTMLButtonElement>('.cancel')!;
    this.newFolderButton =
        this.element.querySelector<HTMLButtonElement>('#new-folder-button')!;
    this.fileTypeSelector =
        this.element.querySelector<HTMLElement>('div.file-type')!;

    const footer = this;
    Object.defineProperty(this.fileTypeSelector, 'value', {
      get() {
        return footer.getSelectValue_();
      },
      enumerable: true,
      configurable: true,
    });
    this.fileTypeSelector.addEventListener(
        'activate', this.onActivate_.bind(this));
    this.fileTypeSelector.addEventListener(
        'click', this.onActivate_.bind(this));
    this.fileTypeSelector.addEventListener('blur', this.onBlur_.bind(this));
    this.fileTypeSelector.addEventListener(
        'keydown', this.onKeyDown_.bind(this));

    this.fileTypeSelectorText =
        this.fileTypeSelector.querySelector<HTMLSpanElement>('span')!;

    // Initialize the element styles.
    this.element.classList.add('button-panel');

    // Set initial label for OK button. The label can be updated dynamically
    // depending on dialog types.
    this.okButtonLabel.textContent = getOkButtonLabel(this.dialogType_);

    // Register event handlers.
    this.filenameInput.addEventListener(
        'keydown', this.onFilenameInputKeyDown_.bind(this));
    this.filenameInput.addEventListener(
        'focus', this.onFilenameInputFocus_.bind(this));
  }

  /**
   * @return Selected filter index. The index is 1 based and 0 means
   *     'any file types'. Keep the meaniing consistent with the index passed to
   *     chrome.fileManagerPrivate.selectFile.
   */
  get selectedFilterIndex(): number {
    return ~~(this.fileTypeSelector as any).value;
  }

  /**
   * Get the 'value' property from the file type selector.
   * @return containing the value attribute of the selected type.
   */
  private getSelectValue_(): string {
    const selected = this.element.querySelector('.selected');
    return selected?.getAttribute('value') || '0';
  }

  /**
   * Open (expand) the fake select drop down.
   */
  selectShowDropDown(options: HTMLElement) {
    options.setAttribute('expanded', 'expanded');
    this.fileTypeSelector.setAttribute('aria-expanded', 'true');
    const selectedOption = options.querySelector('.selected');
    if (selectedOption) {
      this.fileTypeSelector.setAttribute(
          'aria-activedescendant', selectedOption.id);
    }
  }

  /**
   * Hide (collapse) the fake select drop down.
   */
  selectHideDropDown(options: HTMLElement) {
    // TODO: Unify to use only aria-expanded.
    options.removeAttribute('expanded');
    this.fileTypeSelector.setAttribute('aria-expanded', 'false');
    this.fileTypeSelector.removeAttribute('aria-activedescendant');
  }

  private getRequiredOptions_(): HTMLOptionElement {
    return this.element.querySelector<HTMLOptionElement>('.options')!;
  }

  /**
   * Event handler for an activation or click.
   */
  private onActivate_(evt: Event) {
    const options = this.getRequiredOptions_();

    if (evt.target instanceof HTMLOptionElement) {
      this.setOptionSelected(evt.target);
      this.selectHideDropDown(options);
      const changeEvent = new Event('change');
      this.fileTypeSelector.dispatchEvent(changeEvent);
    } else {
      const target = evt.target;
      const ancestor = target instanceof Element ? target.closest('div') : null;
      if (ancestor && ancestor.classList.contains('select')) {
        if (options.getAttribute('expanded') === 'expanded') {
          this.selectHideDropDown(options);
        } else {
          this.selectShowDropDown(options);
        }
      }
    }
  }

  /**
   * Event handler for a blur.
   */
  private onBlur_() {
    const options = this.getRequiredOptions_();

    if (options.getAttribute('expanded') === 'expanded') {
      this.selectHideDropDown(options);
    }
  }

  /**
   * Event handler for a key down.
   */
  private onKeyDown_(evt: KeyboardEvent) {
    const options = this.getRequiredOptions_();
    const selectedItem = options.querySelector('.selected');
    const isExpanded = options.getAttribute('expanded') === 'expanded';

    const fireChangeEvent = () => {
      this.fileTypeSelector.dispatchEvent(new Event('change'));
    };

    const changeSelection = (element: HTMLOptionElement) => {
      this.setOptionSelected(element);
      if (!isExpanded) {
        fireChangeEvent();  // crbug.com/1002410
      }
    };

    switch (evt.key) {
      case 'Escape':
        // If options are open, stop the window from closing.
        if (isExpanded) {
          evt.stopPropagation();
          evt.preventDefault();
        }
        // fall through
      case 'Tab':
        this.selectHideDropDown(options);
        break;
      case 'Enter':
      case ' ':
        if (isExpanded) {
          fireChangeEvent();
          this.selectHideDropDown(options);
        } else {
          this.selectShowDropDown(options);
        }
        break;
      case 'ArrowRight':
        if (isExpanded) {
          break;
        }
        // fall through
      case 'ArrowDown':
        if (selectedItem &&
            selectedItem.nextSibling instanceof HTMLOptionElement) {
          changeSelection(selectedItem.nextSibling);
        }
        break;
      case 'ArrowLeft':
        if (isExpanded) {
          break;
        }
        // fall through
      case 'ArrowUp':
        if (selectedItem &&
            selectedItem.previousSibling instanceof HTMLOptionElement) {
          changeSelection(selectedItem.previousSibling);
        }
        break;
    }
  }

  /**
   * Finds the dialog footer element for the dialog type.
   * @param dialogType Dialog type.
   * @param document Document.
   * @return Dialog footer created with the found element.
   */
  static findDialogFooter(dialogType: DialogType, _document: Document):
      DialogFooter {
    return new DialogFooter(
        dialogType, queryRequiredElement('.dialog-footer'),
        queryRequiredElement('#filename-input-box cr-input') as CrInputElement);
  }

  /**
   * Helper to set the option as the selected one.
   * @param option Element being set as selected.
   */
  setOptionSelected(option: HTMLOptionElement) {
    option.selected = true;
    // Update our fake 'select' HTMLDivElement.
    const existingSelected =
        this.fileTypeSelector.querySelector('.options .selected');
    if (existingSelected) {
      existingSelected.removeAttribute('class');
    }
    option.setAttribute('class', 'selected');
    this.fileTypeSelectorText.innerText = option.innerText;
    this.fileTypeSelectorText.parentElement!.setAttribute(
        'aria-activedescendant', option.id);
    // Force the width of the file-type selector div to be the width
    // of the options area to stop it jittering on selection change.
    if (option.parentNode instanceof Element) {
      let optionsWidth = option.parentNode.getBoundingClientRect().width;
      optionsWidth -= 16 + 12;  // Padding of 16 + 12 px.
      this.fileTypeSelector.setAttribute(
          'style', 'width: ' + optionsWidth + 'px');
    }
  }

  /**
   * Fills the file type list or hides it.
   * @param fileTypes List of file type.
   * @param includeAllFiles Whether the filter includes the 'all
   *     files' item or not.
   */
  initFileTypeFilter(fileTypes: TypeList[], includeAllFiles: boolean): void {
    const optionHost = this.getRequiredOptions_();
    for (const [i, fileType] of fileTypes.entries()) {
      const option = document.createElement('option');
      let description: string|null = fileType.description;
      if (!description) {
        // See if all the extensions in the group have the same description.
        for (const extension of fileType.extensions) {
          const currentDescription = FileListModel.getFileTypeString(
              getFileTypeForName('.' + extension));
          if (!description) {
            // Set the first time.
            description = currentDescription;
          } else if (description !== currentDescription) {
            // No single description, fall through to the extension list.
            description = null;
            break;
          }
        }

        if (!description) {
          // Convert ['jpg', 'png'] to '*.jpg, *.png'.
          description = fileType.extensions
                            .map(
                                (s: string):
                                    string => {
                                      return '*.' + s;
                                    })
                            .join(', ');
        }
      }
      option.innerText = description;
      option.value = String(i + 1);
      option.id = 'file-type-option-' + (i + 1);

      if (fileType.selected) {
        this.setOptionSelected(option);
      }

      optionHost.appendChild(option);
    }

    if (includeAllFiles) {
      const option: HTMLOptionElement = document.createElement('option');
      option.innerText = str('ALL_FILES_FILTER');
      option.value = '0';
      if (this.dialogType_ === DialogType.SELECT_SAVEAS_FILE) {
        this.setOptionSelected(option);
      }
      optionHost.appendChild(option);
    }

    const options = this.fileTypeSelector.querySelectorAll('option');
    if (options.length > 0) {
      // Make sure one of the options is selected to match real <select>.
      let selectedOption: HTMLOptionElement|null =
          this.fileTypeSelector.querySelector('.options .selected');
      if (!selectedOption) {
        selectedOption =
            this.fileTypeSelector.querySelector('.options option')!;
        this.setOptionSelected(selectedOption);
      }
    }
    // Hide the UI if there is actually no choice to be made (0 or 1 option).
    this.fileTypeSelector.hidden = options.length < 2;
  }

  /**
   * @param event Focus event.
   */
  private onFilenameInputFocus_() {
    // On focus we want to select everything but the extension, but
    // Chrome will select-all after the focus event completes.  We
    // schedule a timeout to alter the focus after that happens.
    setTimeout(() => {
      this.selectTargetNameInFilenameInput();
    }, 0);
  }

  /**
   * @param event Key event.
   */
  private onFilenameInputKeyDown_(event: KeyboardEvent) {
    if ((getKeyModifiers(event) + event.keyCode) === '13' /* Enter */) {
      this.okButton.click();
    }
  }

  selectTargetNameInFilenameInput() {
    const selectionEnd = this.filenameInput.value.lastIndexOf('.');
    if (selectionEnd === -1) {
      this.filenameInput.select();
    } else {
      this.filenameInput.select(0, selectionEnd);
    }
  }
}