chromium/chrome/browser/resources/settings/settings_ui/settings_ui.ts

// Copyright 2015 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-ui' implements the UI for the Settings page.
 *
 * Example:
 *
 *    <settings-ui prefs="{{prefs}}"></settings-ui>
 */
import 'chrome://resources/cr_elements/cr_drawer/cr_drawer.js';
import 'chrome://resources/cr_elements/cr_toolbar/cr_toolbar.js';
import 'chrome://resources/cr_elements/cr_toolbar/cr_toolbar_search_field.js';
import 'chrome://resources/cr_elements/cr_page_host_style.css.js';
import 'chrome://resources/cr_elements/icons.html.js';
import 'chrome://resources/cr_elements/cr_shared_vars.css.js';
import '../icons.html.js';
import '../settings_main/settings_main.js';
import '../settings_menu/settings_menu.js';
import '../settings_shared.css.js';
import '../settings_vars.css.js';

import type {SettingsPrefsElement} from '/shared/settings/prefs/prefs.js';
import {CrContainerShadowMixin} from 'chrome://resources/cr_elements/cr_container_shadow_mixin.js';
import type {CrDrawerElement} from 'chrome://resources/cr_elements/cr_drawer/cr_drawer.js';
import type {CrToolbarElement} from 'chrome://resources/cr_elements/cr_toolbar/cr_toolbar.js';
import {FindShortcutMixin} from 'chrome://resources/cr_elements/find_shortcut_mixin.js';
import {listenOnce} from 'chrome://resources/js/util.js';
import type {DomIf} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {resetGlobalScrollTargetForTesting, setGlobalScrollTarget} from '../global_scroll_target_mixin.js';
import {loadTimeData} from '../i18n_setup.js';
import type {PageVisibility} from '../page_visibility.js';
import {pageVisibility} from '../page_visibility.js';
import {routes} from '../route.js';
import type {Route} from '../router.js';
import {RouteObserverMixin, Router} from '../router.js';
import type {SettingsMainElement} from '../settings_main/settings_main.js';
import type {SettingsMenuElement} from '../settings_menu/settings_menu.js';

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

declare global {
  interface HTMLElementEventMap {
    'refresh-pref': CustomEvent<string>;
  }
}

export interface SettingsUiElement {
  $: {
    container: HTMLElement,
    drawer: CrDrawerElement,
    drawerTemplate: DomIf,
    leftMenu: SettingsMenuElement,
    main: SettingsMainElement,
    toolbar: CrToolbarElement,
    prefs: SettingsPrefsElement,
  };
}

const SettingsUiElementBase = RouteObserverMixin(
    CrContainerShadowMixin(FindShortcutMixin(PolymerElement)));

export class SettingsUiElement extends SettingsUiElementBase {
  static get is() {
    return 'settings-ui';
  }

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

  static get properties() {
    return {
      /**
       * Preferences state.
       */
      prefs: Object,

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

      narrow_: {
        type: Boolean,
        observer: 'onNarrowChanged_',
      },

      pageVisibility_: {type: Object, value: pageVisibility},

      lastSearchQuery_: {
        type: String,
        value: '',
      },
    };
  }

  private toolbarSpinnerActive_: boolean;
  private narrow_: boolean;
  private pageVisibility_: PageVisibility;
  private lastSearchQuery_: string;

  constructor() {
    super();

    Router.getInstance().initializeRouteFromUrl();
  }

  override ready() {
    super.ready();

    // Lazy-create the drawer the first time it is opened or swiped into view.
    listenOnce(this.$.drawer, 'cr-drawer-opening', () => {
      this.$.drawerTemplate.if = true;
    });

    window.addEventListener('popstate', () => {
      this.$.drawer.cancel();
    });

    window.CrPolicyStrings = {
      controlledSettingExtension:
          loadTimeData.getString('controlledSettingExtension'),
      controlledSettingExtensionWithoutName:
          loadTimeData.getString('controlledSettingExtensionWithoutName'),
      controlledSettingPolicy:
          loadTimeData.getString('controlledSettingPolicy'),
      controlledSettingRecommendedMatches:
          loadTimeData.getString('controlledSettingRecommendedMatches'),
      controlledSettingRecommendedDiffers:
          loadTimeData.getString('controlledSettingRecommendedDiffers'),
      controlledSettingChildRestriction:
          loadTimeData.getString('controlledSettingChildRestriction'),
      controlledSettingParent:
          loadTimeData.getString('controlledSettingParent'),

      // <if expr="chromeos_ash">
      controlledSettingShared:
          loadTimeData.getString('controlledSettingShared'),
      controlledSettingWithOwner:
          loadTimeData.getString('controlledSettingWithOwner'),
      controlledSettingNoOwner:
          loadTimeData.getString('controlledSettingNoOwner'),
      // </if>
    };

    this.addEventListener('show-container', () => {
      this.$.container.style.visibility = 'visible';
    });

    this.addEventListener('hide-container', () => {
      this.$.container.style.visibility = 'hidden';
    });

    this.addEventListener('refresh-pref', this.onRefreshPref_.bind(this));
  }

  override connectedCallback() {
    super.connectedCallback();

    document.documentElement.classList.remove('loading');

    // Preload bold Roboto so it doesn't load and flicker the first time used.
    // https://github.com/microsoft/TypeScript/issues/13569
    (document as any).fonts.load('bold 12px Roboto');
    setGlobalScrollTarget(this.$.container);
  }

  override disconnectedCallback() {
    super.disconnectedCallback();

    Router.getInstance().resetRouteForTesting();
    resetGlobalScrollTargetForTesting();
  }

  override currentRouteChanged(route: Route) {
    if (route === routes.PRIVACY_GUIDE) {
      // Privacy guide has a multi-card layout, which only needs shadows to
      // show when there is more content to scroll.
      this.setForceDropShadows(false);
      this.enableScrollObservation(true);
    } else if (route.depth <= 1) {
      // Main page uses scroll position to determine whether a shadow should
      // be shown.
      this.setForceDropShadows(false);
      this.enableScrollObservation(true);
    } else if (!route.isNavigableDialog) {
      // Sub-pages always show the top shadow, regardless of scroll position.
      this.enableScrollObservation(false);
      this.setForceDropShadows(true);
    }

    const urlSearchQuery =
        Router.getInstance().getQueryParameters().get('search') || '';
    if (urlSearchQuery === this.lastSearchQuery_) {
      return;
    }

    this.lastSearchQuery_ = urlSearchQuery;

    const toolbar =
        this.shadowRoot!.querySelector<CrToolbarElement>('cr-toolbar')!;
    const searchField = toolbar.getSearchField();

    // If the search was initiated by directly entering a search URL, need to
    // sync the URL parameter to the textbox.
    if (urlSearchQuery !== searchField.getValue()) {
      // Setting the search box value without triggering a 'search-changed'
      // event, to prevent an unnecessary duplicate entry in |window.history|.
      searchField.setValue(urlSearchQuery, true /* noEvent */);
    }

    this.$.main.searchContents(urlSearchQuery);
  }

  // Override FindShortcutMixin methods.
  override handleFindShortcut(modalContextOpen: boolean) {
    if (modalContextOpen) {
      return false;
    }
    this.shadowRoot!.querySelector<CrToolbarElement>('cr-toolbar')!
        .getSearchField()
        .showAndFocus();
    return true;
  }

  // Override FindShortcutMixin methods.
  override searchInputHasFocus() {
    return this.shadowRoot!.querySelector<CrToolbarElement>('cr-toolbar')!
        .getSearchField()
        .isSearchFocused();
  }

  private onRefreshPref_(e: CustomEvent<string>) {
    return this.$.prefs.refresh(e.detail);
  }

  /**
   * Handles the 'search-changed' event fired from the toolbar.
   */
  private onSearchChanged_(e: CustomEvent<string>) {
    const query = e.detail;
    Router.getInstance().navigateTo(
        routes.BASIC,
        query.length > 0 ?
            new URLSearchParams('search=' + encodeURIComponent(query)) :
            undefined,
        /* removeSearch */ true);
  }

  /**
   * Called when a section is selected.
   */
  private onIronActivate_() {
    this.$.drawer.close();
  }

  private onMenuButtonClick_() {
    this.$.drawer.toggle();
  }

  /**
   * When this is called, The drawer animation is finished, and the dialog no
   * longer has focus. The selected section will gain focus if one was
   * selected. Otherwise, the drawer was closed due being canceled, and the
   * main settings container is given focus. That way the arrow keys can be
   * used to scroll the container, and pressing tab focuses a component in
   * settings.
   */
  private onMenuClose_() {
    if (!this.$.drawer.wasCanceled()) {
      // If a navigation happened, MainPageMixin#currentRouteChanged
      // handles focusing the corresponding section.
      return;
    }

    // Add tab index so that the container can be focused.
    this.$.container.setAttribute('tabindex', '-1');
    this.$.container.focus();

    listenOnce(this.$.container, ['blur', 'pointerdown'], () => {
      this.$.container.removeAttribute('tabindex');
    });
  }

  private onNarrowChanged_() {
    if (this.$.drawer.open && !this.narrow_) {
      this.$.drawer.close();
    }

    const focusedElement = this.shadowRoot!.activeElement;
    if (this.narrow_ && focusedElement === this.$.leftMenu) {
      // If changed from non-narrow to narrow and the focus was on the left
      // menu, move focus to the button that opens the drawer menu.
      this.$.toolbar.focusMenuButton();
    } else if (!this.narrow_ && this.$.toolbar.isMenuFocused()) {
      // If changed from narrow to non-narrow and the focus was on the button
      // that opens the drawer menu, move focus to the left menu.
      this.$.leftMenu.focusFirstItem();
    } else if (
        !this.narrow_ &&
        focusedElement === this.shadowRoot!.querySelector('#drawerMenu')) {
      // If changed from narrow to non-narrow and the focus was in the drawer
      // menu, wait for the drawer to close and then move focus on the left
      // menu. The drawer has a dialog element in it so moving focus to an
      // element outside the dialog while it is open will not work.
      const boundCloseListener = () => {
        this.$.leftMenu.focusFirstItem();
        this.$.drawer.removeEventListener('close', boundCloseListener);
      };
      this.$.drawer.addEventListener('close', boundCloseListener);
    }
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'settings-ui': SettingsUiElement;
  }
}

customElements.define(SettingsUiElement.is, SettingsUiElement);