chromium/chrome/browser/resources/ash/settings/common/app_language_selection_dialog/app_language_selection_dialog.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 'app-language-selection-dialog' is a dialog to select
 * per-app language. The component supports suggested items, and filtering
 * by search query.
 * Note: `App` object must be present before showing this dialog.
 */
import 'chrome://resources/ash/common/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/ash/common/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/ash/common/cr_elements/cr_search_field/cr_search_field.js';
import 'chrome://resources/ash/common/cr_elements/cr_dialog/cr_dialog.js';
import 'chrome://resources/ash/common/cr_elements/cr_shared_style.css.js';
import './app_language_selection_item.js';
import '../../settings_shared.css.js';

import {PrefsMixin} from '/shared/settings/prefs/prefs_mixin.js';
import {CrDialogElement} from 'chrome://resources/ash/common/cr_elements/cr_dialog/cr_dialog.js';
import {CrSearchFieldElement} from 'chrome://resources/ash/common/cr_elements/cr_search_field/cr_search_field.js';
import {I18nMixin} from 'chrome://resources/ash/common/cr_elements/i18n_mixin.js';
import {App, Locale} from 'chrome://resources/cr_components/app_management/app_management.mojom-webui.js';
import {getAppIcon} from 'chrome://resources/cr_components/app_management/util.js';
import {DomRepeatEvent, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {AppManagementBrowserProxy} from '../app_management/browser_proxy.js';

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

// Keep this in sync with tools/metrics/histograms/metadata/arc/histograms.xml
// Arc.AppLanguageSwitch.{SettingsPage}.TargetLanguage.
export enum AppLanguageSelectionDialogEntryPoint {
  APPS_MANAGEMENT_PAGE = 'AppsManagementPage',
  LANGUAGES_PAGE = 'LanguagesPage',
}

export interface AppLanguageSelectionDialogElement {
  $: {
    dialog: CrDialogElement,
    search: CrSearchFieldElement,
  };
}

const AppLanguageSelectionDialogElementBase =
    PrefsMixin(I18nMixin(PolymerElement));

export class AppLanguageSelectionDialogElement extends
    AppLanguageSelectionDialogElementBase {
  static get is() {
    return 'app-language-selection-dialog' as const;
  }

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

  static get properties() {
    return {
      app: Object,
      filteredLanguages_: {
        type: Array,
        computed:
            'getFilteredLanguages_(searchQuery_.length, suggestedLanguages_)',
      },
      entryPoint: String,
    };
  }

  // Public API: Bidirectional data flow.
  // prefs is provided by PrefsMixin.

  // App must be present when this dialog is shown.
  app: App;
  entryPoint: AppLanguageSelectionDialogEntryPoint;
  private suggestedLanguages_: Locale[] = [];
  private filteredLanguages_: Locale[] = [];
  private selectedLanguage_?: Locale;
  private searchQuery_ = '';

  override ready(): void {
    super.ready();
    this.initializeSuggestedLanguages_();
  }

  private onCancelButtonClick_(): void {
    this.$.dialog.close();
  }

  private onActionButtonClick_(): void {
    AppManagementBrowserProxy.getInstance().handler.setAppLocale(
        this.app.id,
        this.selectedLanguage_!.localeTag,
    );
    chrome.metricsPrivate.recordSparseValueWithHashMetricName(
        `Arc.AppLanguageSwitch.${this.entryPoint}.TargetLanguage`,
        this.selectedLanguage_!.localeTag);
    this.$.dialog.close();
  }

  private shouldDisableActionButton_(): boolean {
    return !this.selectedLanguage_;
  }

  // 'on-search-changed' event listener on a <cr-search-field>.
  private onSearchChanged_(e: CustomEvent<string>): void {
    this.searchQuery_ = e.detail.toLocaleLowerCase();
  }

  // Used to display "search result is empty" text.
  private isSearchResultEmpty_(): boolean {
    return this.searchQuery_.length > 0 && this.filteredLanguages_.length === 0;
  }

  // 'on-keydown' event listener on a <cr-search-field>.
  private onKeydown_(e: KeyboardEvent): void {
    // Close dialog if 'esc' is pressed and the search box is already empty.
    if (e.key === 'Escape' && !this.$.search.getValue().trim()) {
      this.$.dialog.close();
    } else if (e.key !== 'PageDown' && e.key !== 'PageUp') {
      this.$.search.scrollIntoView();
    }
  }

  // 'on-click' event listener on a <app-language-selection-item>.
  private toggleSelection_(e: DomRepeatEvent<Locale>): void {
    if (e.model.item === this.selectedLanguage_) {
      this.selectedLanguage_ = undefined;
    } else {
      this.selectedLanguage_ = e.model.item;
    }
  }

  // Defines whether a selected-icon should be added to the item.
  private isItemSelected_(item: Locale): boolean {
    if (!this.selectedLanguage_) {
      return false;
    }
    return this.selectedLanguage_.localeTag === item.localeTag;
  }

  // Hide suggestedLanguages along with "Device Language", and "All languages"
  // label if searchQuery exists.
  private isSearchQueryPresent(): boolean {
    return this.searchQuery_.length > 0;
  }

  // The only case where `filteredLanguages` is completely hidden is when
  // there's only 1 app-language available, and it's already selected. In this
  // case, we'll only display suggestedLanguages (deviceLanguage,
  // selectedLanguage).
  private showFilteredLanguages(): boolean {
    return this.filteredLanguages_.length > 0;
  }

  // `suggestedLanguages_` is immutable throughout the dialog lifecycle, hence
  // it's better to pre-populate once here.
  private initializeSuggestedLanguages_(): void {
    const defaultDeviceLanguage: Locale = {
      localeTag: '',
      displayName: this.i18n('appLanguageDeviceLanguageLabel'),
      nativeDisplayName: '',
    };
    // Possibly add selected locale to suggested list and init
    // currently-selected language.
    const suggestedLanguages = [defaultDeviceLanguage];
    if (this.app.selectedLocale && this.app.selectedLocale.localeTag !== '') {
      suggestedLanguages.push(this.app.selectedLocale);
      this.selectedLanguage_ = this.app.selectedLocale;
    } else {
      // Set device language as selected if no other locale selected.
      this.selectedLanguage_ = defaultDeviceLanguage;
    }
    const lastSetAppLocaleTag = this.getPref('arc.last_set_app_locale').value;
    // Add last-set-app-locale into suggestions if it's supported by app and
    // hasn't been included yet.
    const lastSetAppLocale = this.app.supportedLocales.find(
        locale => locale.localeTag === lastSetAppLocaleTag);
    if (lastSetAppLocale &&
        !suggestedLanguages.find(
            locale => locale.localeTag === lastSetAppLocale.localeTag)) {
      suggestedLanguages.push(lastSetAppLocale);
    }

    this.suggestedLanguages_ = suggestedLanguages;
  }

  // Display list of all supported languages, minus suggestedLanguages, and
  // filtered by search query.
  private getFilteredLanguages_(): Locale[] {
    let filteredItems;
    if (this.searchQuery_.length === 0) {
      // Filter out elements in suggestedList
      filteredItems = this.app.supportedLocales.filter(
          locale => !this.suggestedLanguages_.some(
              suggestedLanguage =>
                  suggestedLanguage.localeTag === locale.localeTag));
    } else {
      // Filter out based on search query.
      filteredItems = this.app.supportedLocales.filter(
          locale => locale.displayName.toLocaleLowerCase().includes(
                        this.searchQuery_) ||
              locale.nativeDisplayName.toLocaleLowerCase().includes(
                  this.searchQuery_));
    }
    return filteredItems.sort(
        (a, b) => a.displayName.localeCompare(b.displayName));
  }

  private iconUrlFromApp_(): string {
    return getAppIcon(this.app);
  }
}

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

customElements.define(
    AppLanguageSelectionDialogElement.is, AppLanguageSelectionDialogElement);