chromium/chrome/browser/resources/settings/settings_page/settings_subpage.ts

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

/**
 * @fileoverview
 * 'settings-subpage' shows a subpage beneath a subheader. The header contains
 * the subpage title, a search field and a back icon.
 */

import '//resources/cr_elements/cr_icon_button/cr_icon_button.js';
import '//resources/cr_elements/cr_search_field/cr_search_field.js';
import '//resources/cr_elements/icons.html.js';
import '//resources/cr_elements/cr_shared_style.css.js';
import '//resources/polymer/v3_0/paper-spinner/paper-spinner-lite.js';
import '../settings_shared.css.js';
import '../site_favicon.js';

import type {CrSearchFieldElement} from '//resources/cr_elements/cr_search_field/cr_search_field.js';
import type {FindShortcutMixinInterface} from '//resources/cr_elements/find_shortcut_mixin.js';
import {FindShortcutMixin} from '//resources/cr_elements/find_shortcut_mixin.js';
import type {I18nMixinInterface} from '//resources/cr_elements/i18n_mixin.js';
import {I18nMixin} from '//resources/cr_elements/i18n_mixin.js';
import {assert} from '//resources/js/assert.js';
import {focusWithoutInk} from '//resources/js/focus_without_ink.js';
import {listenOnce} from '//resources/js/util.js';
import {IronResizableBehavior} from '//resources/polymer/v3_0/iron-resizable-behavior/iron-resizable-behavior.js';
import {afterNextRender, mixinBehaviors, PolymerElement} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {EventTracker} from 'chrome://resources/js/event_tracker.js';

import {loadTimeData} from '../i18n_setup.js';
import type {Route, RouteObserverMixinInterface} from '../router.js';
import {RouteObserverMixin, Router} from '../router.js';

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


const SETTING_ID_URL_PARAM_NAME: string = 'settingId';

/**
 * Retrieves the setting ID saved in the URL's query parameter. Returns null if
 * setting ID is unavailable.
 */
function getSettingIdParameter(): string|null {
  return Router.getInstance().getQueryParameters().get(
      SETTING_ID_URL_PARAM_NAME);
}

export interface SettingsSubpageElement {
  $: {
    closeButton: HTMLElement,
  };
}

const SettingsSubpageElementBase =
    mixinBehaviors(
        [IronResizableBehavior],
        RouteObserverMixin(FindShortcutMixin(I18nMixin(PolymerElement)))) as {
      new (): PolymerElement & FindShortcutMixinInterface & I18nMixinInterface &
          RouteObserverMixinInterface,
    };

export class SettingsSubpageElement extends SettingsSubpageElementBase {
  static get is() {
    return 'settings-subpage';
  }

  static get properties() {
    return {
      pageTitle: String,

      /** Setting this will display the icon at the given URL. */
      titleIcon: String,

      /** Setting this will display the favicon of the website. */
      faviconSiteUrl: String,

      learnMoreUrl: String,

      /** Setting a |searchLabel| will enable search. */
      searchLabel: String,

      searchTerm: {
        type: String,
        notify: true,
        value: '',
      },

      /** If true shows an active spinner at the end of the subpage header. */
      showSpinner: {
        type: Boolean,
        value: false,
      },

      /**
       * Title (i.e., tooltip) to be displayed on the spinner. If |showSpinner|
       * is false, this field has no effect.
       */
      spinnerTitle: {
        type: String,
        value: '',
      },

      /**
       * Whether we should hide the "close" button to get to the previous page.
       */
      hideCloseButton: {
        type: Boolean,
        value: false,
      },

      /**
       * Indicates which element triggers this subpage. Used by the searching
       * algorithm to show search bubbles. It is |null| for subpages that are
       * skipped during searching.
       */
      associatedControl: {
        type: Object,
        value: null,
      },

      /**
       * Whether the subpage search term should be preserved across navigations.
       */
      preserveSearchTerm: {
        type: Boolean,
        value: false,
      },

      active_: {
        type: Boolean,
        value: false,
        observer: 'onActiveChanged_',
      },
    };
  }

  pageTitle: string;
  titleIcon: string;
  faviconSiteUrl: string;
  learnMoreUrl: string;
  searchLabel: string;
  searchTerm: string;
  showSpinner: boolean;
  spinnerTitle: string;
  hideCloseButton: boolean;
  associatedControl: HTMLElement|null;
  preserveSearchTerm: boolean;
  private active_: boolean;
  private lastActiveValue_: boolean = false;
  private eventTracker_: EventTracker|null = null;

  constructor() {
    super();

    // Override FindShortcutMixin property.
    this.findShortcutListenOnAttach = false;
  }

  override connectedCallback() {
    super.connectedCallback();

    if (this.searchLabel) {
      // |searchLabel| should not change dynamically.
      this.eventTracker_ = new EventTracker();
      this.eventTracker_.add(
          this, 'clear-subpage-search', this.onClearSubpageSearch_);
    }
  }

  override disconnectedCallback() {
    super.disconnectedCallback();

    if (this.eventTracker_) {
      // |searchLabel| should not change dynamically.
      this.eventTracker_.removeAll();
    }
  }

  private getSearchField_(): Promise<CrSearchFieldElement> {
    let searchField = this.shadowRoot!.querySelector('cr-search-field');
    if (searchField) {
      return Promise.resolve(searchField);
    }

    return new Promise(resolve => {
      listenOnce(this, 'dom-change', () => {
        searchField = this.shadowRoot!.querySelector('cr-search-field');
        assert(!!searchField);
        resolve(searchField);
      });
    });
  }

  /** Restore search field value from URL search param */
  private restoreSearchInput_() {
    const searchField = this.shadowRoot!.querySelector('cr-search-field')!;
    const urlSearchQuery =
        Router.getInstance().getQueryParameters().get('searchSubpage') || '';
    this.searchTerm = urlSearchQuery;
    searchField.setValue(urlSearchQuery);
  }

  /** Preserve search field value to URL search param */
  private preserveSearchInput_() {
    const query = this.searchTerm;
    const searchParams = query.length > 0 ?
        new URLSearchParams('searchSubpage=' + encodeURIComponent(query)) :
        undefined;
    const currentRoute = Router.getInstance().getCurrentRoute();
    Router.getInstance().navigateTo(currentRoute, searchParams);
  }

  /** Focuses the back button when page is loaded. */
  focusBackButton() {
    if (this.hideCloseButton) {
      return;
    }
    afterNextRender(this, () => focusWithoutInk(this.$.closeButton));
  }

  override currentRouteChanged(newRoute: Route, oldRoute?: Route) {
    this.active_ = this.getAttribute('route-path') === newRoute.path;
    if (this.active_ && this.searchLabel && this.preserveSearchTerm) {
      this.getSearchField_().then(() => this.restoreSearchInput_());
    }
    if (!oldRoute && !getSettingIdParameter()) {
      // If a settings subpage is opened directly (i.e the |oldRoute| is null,
      // e.g via an OS settings search result that surfaces from the Chrome OS
      // launcher, or linking from other places of Chrome UI), the back button
      // should be focused since it's the first actionable element in the the
      // subpage. An exception is when a setting is deep linked, focus that
      // setting instead of back button.
      this.focusBackButton();
    }
  }

  private onActiveChanged_() {
    if (this.lastActiveValue_ === this.active_) {
      return;
    }
    this.lastActiveValue_ = this.active_;

    if (this.active_ && this.pageTitle) {
      document.title =
          loadTimeData.getStringF('settingsAltPageTitle', this.pageTitle);
    }

    if (!this.searchLabel) {
      return;
    }

    const searchField = this.shadowRoot!.querySelector('cr-search-field');
    if (searchField) {
      searchField.setValue('');
    }

    if (this.active_) {
      this.becomeActiveFindShortcutListener();
    } else {
      this.removeSelfAsFindShortcutListener();
    }
  }

  /** Clear the value of the search field. */
  private onClearSubpageSearch_(e: Event) {
    e.stopPropagation();
    this.shadowRoot!.querySelector('cr-search-field')!.setValue('');
  }

  private onBackClick_() {
    Router.getInstance().navigateToPreviousRoute();
  }

  private onHelpClick_() {
    window.open(this.learnMoreUrl);
  }

  private onSearchChanged_(e: CustomEvent<string>) {
    if (this.searchTerm === e.detail) {
      return;
    }

    this.searchTerm = e.detail;
    if (this.preserveSearchTerm && this.active_) {
      this.preserveSearchInput_();
    }
  }

  private getBackButtonAriaLabel_() {
    return this.i18n('subpageBackButtonAriaLabel', this.pageTitle);
  }

  private getBackButtonAriaRoleDescription_() {
    return this.i18n('subpageBackButtonAriaRoleDescription', this.pageTitle);
  }

  private getLearnMoreAriaLabel_() {
    return this.i18n('subpageLearnMoreAriaLabel', this.pageTitle);
  }

  // Override FindShortcutMixin methods.
  override handleFindShortcut(modalContextOpen: boolean) {
    if (modalContextOpen) {
      return false;
    }
    this.shadowRoot!.querySelector('cr-search-field')!.getSearchInput().focus();
    return true;
  }

  // Override FindShortcutMixin methods.
  override searchInputHasFocus() {
    const field = this.shadowRoot!.querySelector('cr-search-field')!;
    return field.getSearchInput() === field.shadowRoot!.activeElement;
  }

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

declare global {
  interface HTMLElementTagNameMap {
    'settings-subpage': SettingsSubpageElement;
  }
}

customElements.define(SettingsSubpageElement.is, SettingsSubpageElement);