chromium/chrome/browser/resources/ash/settings/device_page/customize_buttons_subsection.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
 * 'customize-buttons-subsection' contains a list of 'customize-button-row'
 * elements that allow users to remap buttons to actions or key combinations.
 */

import 'chrome://resources/ash/common/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/ash/common/cr_elements/cr_dialog/cr_dialog.js';
import 'chrome://resources/ash/common/cr_elements/cr_input/cr_input.js';
import '../settings_shared.css.js';
import './customize_button_row.js';
import './key_combination_input_dialog.js';

import {getInstance as getAnnouncerInstance} from 'chrome://resources/ash/common/cr_elements/cr_a11y_announcer/cr_a11y_announcer.js';
import {CrDialogElement} from 'chrome://resources/ash/common/cr_elements/cr_dialog/cr_dialog.js';
import {I18nMixin} from 'chrome://resources/ash/common/cr_elements/i18n_mixin.js';
import {assert} from 'chrome://resources/js/assert.js';
import {PolymerElementProperties} from 'chrome://resources/polymer/v3_0/polymer/interfaces.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {ReorderButtonEvent, ShowKeyCustomizationDialogEvent, ShowRenamingDialogEvent} from './customize_button_row.js';
import {getTemplate} from './customize_buttons_subsection.html.js';
import {DragAndDropManager, OnDropCallback} from './drag_and_drop_manager.js';
import {ActionChoice, ButtonRemapping, MetaKey} from './input_device_settings_types.js';
import {KeyCombinationInputDialogElement} from './key_combination_input_dialog.js';

const MAX_INPUT_LENGTH = 32;

export interface CustomizeButtonsSubsectionElement {
  $: {
    keyCombinationInputDialog: KeyCombinationInputDialogElement,
    subsection: HTMLDivElement,
    renamingDialog: CrDialogElement,
  };
}

declare global {
  interface HTMLElementEventMap {
    'show-renaming-dialog': ShowRenamingDialogEvent;
    'show-key-combination-dialog': ShowKeyCustomizationDialogEvent;
    'reorder-button': ReorderButtonEvent;
  }
}

const MAX_BUTTON_NAME_INPUT_LENGTH = 32;

const CustomizeButtonsSubsectionElementBase = I18nMixin(PolymerElement);

export class CustomizeButtonsSubsectionElement extends
    CustomizeButtonsSubsectionElementBase {
  static get is() {
    return 'customize-buttons-subsection' as const;
  }

  static get template(): HTMLTemplateElement {
    return getTemplate();
  }

  static get properties(): PolymerElementProperties {
    return {
      actionList: {
        type: Array,
      },

      buttonRemappingList: {
        type: Array,
      },

      selectedButton_: {
        type: Object,
      },

      selectedButtonName_: {
        type: String,
        value: '',
        observer: 'onNameInputChanged_',
      },

      selectedButtonIndex_: {
        type: Number,
      },

      buttonNameInvalid_: {
        type: Boolean,
        value: false,
        reflectToAttribute: true,
      },

      isSaveButtonDisabled_: {
        type: Boolean,
        value: false,
      },

      duplicateButtonName_: {
        type: Boolean,
        value: false,
      },

      metaKey: Object,

      /** Used to reference the maxInputLength constant in HTML. */
      maxInputLength: {
        type: Number,
        value: MAX_INPUT_LENGTH,
        readOnly: true,
      },
    };
  }

  buttonRemappingList: ButtonRemapping[];
  actionList: ActionChoice[];
  metaKey: MetaKey = MetaKey.kSearch;
  private selectedButton_: ButtonRemapping;
  private selectedButtonIndex_: number;
  private selectedButtonName_: string;
  private dragAndDropManager: DragAndDropManager = new DragAndDropManager();
  private buttonNameInvalid_: boolean;
  private isSaveButtonDisabled_: boolean;
  private duplicateButtonName_: boolean;
  private readonly maxInputLength: number;

  override connectedCallback(): void {
    super.connectedCallback();
    this.addEventListener('show-renaming-dialog', this.showRenamingDialog_);
    this.addEventListener(
        'show-key-combination-dialog', this.showKeyCombinationDialog_);
    this.dragAndDropManager.init(this, this.onDrop_.bind(this));
    this.addEventListener(
        'key-combination-dialog-close', this.onKeyCombinationDialogClose_);
    this.addEventListener('reorder-button', (e: ReorderButtonEvent) => {
      this.onDrop_(e.detail.originIndex, e.detail.destinationIndex);
    });
  }

  override disconnectedCallback(): void {
    super.disconnectedCallback();
    this.dragAndDropManager.destroy();
    this.removeEventListener('show-renaming-dialog', this.showRenamingDialog_);
    this.removeEventListener(
        'show-key-combination-dialog', this.showKeyCombinationDialog_);
    this.dragAndDropManager.init(this, this.onDrop_.bind(this));
    this.removeEventListener(
        'key-combination-dialog-close', this.onKeyCombinationDialogClose_);
    this.removeEventListener('reorder-button', (e: ReorderButtonEvent) => {
      this.onDrop_(e.detail.originIndex, e.detail.destinationIndex);
    });
  }

  private showRenamingDialog_(e: ShowRenamingDialogEvent): void {
    this.selectedButtonIndex_ = e.detail.buttonIndex;
    this.selectedButton_ = this.buttonRemappingList[this.selectedButtonIndex_];
    this.selectedButtonName_ = this.selectedButton_.name;
    this.buttonNameInvalid_ = false;
    this.isSaveButtonDisabled_ = false;
    this.duplicateButtonName_ = false;
    this.$.renamingDialog.showModal();
  }

  /**
   * Returns a formatted string containing the current number of characters
   * entered in the input compared to the maximum number of characters allowed.
   */
  private getInputCountString_(buttonName: string): string {
    // minimumIntegerDigits is 2 because we want to show a leading zero if
    // length is less than 10.
    return this.i18n(
        'buttonRenamingDialogInputCharCount',
        buttonName.length.toLocaleString(
            /*locales=*/ undefined, {minimumIntegerDigits: 2}),
        MAX_BUTTON_NAME_INPUT_LENGTH.toLocaleString());
  }

  private showKeyCombinationDialog_(e: ShowKeyCustomizationDialogEvent): void {
    this.selectedButtonIndex_ = e.detail.buttonIndex;
    this.$.keyCombinationInputDialog.showModal();
  }

  private cancelRenamingDialogClicked_(): void {
    this.$.renamingDialog.close();
  }

  private saveRenamingDialogClicked_(): void {
    if (this.isSaveButtonDisabled_) {
      return;
    }

    if (this.sameButtonNameExists_()) {
      this.buttonNameInvalid_ = true;
      this.duplicateButtonName_ = true;
      return;
    }

    this.updateButtonName_();
    this.$.renamingDialog.close();
  }

  private onKeyDownInRenamingDialog_(event: KeyboardEvent): void {
    if (event.key === 'Enter') {
      this.saveRenamingDialogClicked_();
    }
  }

  private onNameInputChanged_(_newValue: string, oldValue: string): void {
    // If oldValue.length > MAX_BUTTON_NAME_INPUT_LENGTH, the user attempted
    // to enter more than the max limit, this method was called and it was
    // truncated, and then this method was called one more time.
    this.buttonNameInvalid_ =
        !!oldValue && oldValue.length > MAX_BUTTON_NAME_INPUT_LENGTH;
    this.duplicateButtonName_ = false;
    // Truncate the name to maxInputLength.
    this.selectedButtonName_ =
        this.selectedButtonName_.substring(0, MAX_BUTTON_NAME_INPUT_LENGTH);
    this.isSaveButtonDisabled_ = this.selectedButtonName_ === '';
  }

  /**
   * Button names within one device should be unique.
   */
  private sameButtonNameExists_(): boolean {
    for (const button of this.buttonRemappingList) {
      if (button.name !== this.selectedButton_.name &&
          button.name === this.selectedButtonName_) {
        return true;
      }
    }

    return false;
  }

  private updateButtonName_(): void {
    if (!!this.selectedButtonName_ &&
        this.selectedButton_.name !== this.selectedButtonName_) {
      this.set(
          `buttonRemappingList.${this.selectedButtonIndex_}.name`,
          this.selectedButtonName_);
      this.dispatchEvent(new CustomEvent('button-remapping-changed', {
        bubbles: true,
        composed: true,
      }));
    }
    this.selectedButtonName_ = '';
  }

  private onDrop_: OnDropCallback =
      (originIndex: number, destinationIndex: number) => {
        if (originIndex < 0 || originIndex >= this.buttonRemappingList.length ||
            destinationIndex < 0 ||
            destinationIndex >= this.buttonRemappingList.length) {
          return;
        }

        // Move the item in this.buttonRemappingList from originIndex
        // to destinationIndex.
        const movedItem = this.buttonRemappingList[originIndex];
        // Remove item at origin index
        this.splice('buttonRemappingList', originIndex, 1);
        // Add item at destination index
        this.splice('buttonRemappingList', destinationIndex, 0, movedItem);

        // Announce which row the item moved to.
        getAnnouncerInstance().announce(this.i18n(
            'buttonReorderingAriaAnnouncement', destinationIndex + 1));

        // Focus the dropdown element for where this button is moving so focus
        // moves with the element.
        const buttonRows =
            this.$.subsection.querySelectorAll('customize-button-row');
        assert(!!buttonRows && buttonRows.length > destinationIndex);
        buttonRows[destinationIndex].focusReorderingButton();

        this.dispatchEvent(new CustomEvent('button-remapping-changed', {
          bubbles: true,
          composed: true,
        }));
      };

  private onKeyCombinationDialogClose_(): void {
    const buttonRows =
        this.$.subsection.querySelectorAll('customize-button-row');

    assert(!!buttonRows && buttonRows.length > this.selectedButtonIndex_);
    buttonRows[this.selectedButtonIndex_].focus();
  }
}

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

customElements.define(
    CustomizeButtonsSubsectionElement.is, CustomizeButtonsSubsectionElement);