chromium/chrome/browser/resources/ash/settings/os_settings_ui/os_settings_ui.ts

// Copyright 2019 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/polymer/v3_0/iron-media-query/iron-media-query.js';
import '/shared/settings/prefs/prefs.js';
import 'chrome://resources/ash/common/cr_elements/cr_drawer/cr_drawer.js';
import 'chrome://resources/ash/common/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/ash/common/cr_elements/cr_page_host_style.css.js';
import 'chrome://resources/ash/common/cr_elements/icons.html.js';
import 'chrome://resources/ash/common/cr_elements/cr_shared_vars.css.js';
import '../os_settings_menu/os_settings_menu.js';
import '../os_settings_main/os_settings_main.js';
import '../toolbar/toolbar.js';
import '../settings_shared.css.js';
import '../settings_vars.css.js';

import {SettingsPrefsElement} from '/shared/settings/prefs/prefs.js';
import {CrContainerShadowMixin} from 'chrome://resources/ash/common/cr_elements/cr_container_shadow_mixin.js';
import {CrDrawerElement} from 'chrome://resources/ash/common/cr_elements/cr_drawer/cr_drawer.js';
import {FindShortcutMixin} from 'chrome://resources/ash/common/cr_elements/find_shortcut_mixin.js';
import {assert} from 'chrome://resources/js/assert.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {listenOnce} from 'chrome://resources/js/util.js';
import {Debouncer, DomIf, microTask, PolymerElement, timeOut} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {castExists} from '../assert_extras.js';
import {setGlobalScrollTarget} from '../common/global_scroll_target_mixin.js';
import {isRevampWayfindingEnabled} from '../common/load_time_booleans.js';
import {RouteObserverMixin} from '../common/route_observer_mixin.js';
import type {UserActionSettingPrefChangeEvent} from '../common/types.js';
import {recordClick, recordNavigation, recordPageBlur, recordPageFocus, recordSettingChange, recordSettingChangeForUnmappedPref} from '../metrics_recorder.js';
import {convertPrefToSettingMetric} from '../metrics_utils.js';
import {createPageAvailability, OsPageAvailability} from '../os_page_availability.js';
import {Route, Router} from '../router.js';
import {SettingsToolbarElement} from '../toolbar/toolbar.js';

import {OsSettingsHatsBrowserProxy, OsSettingsHatsBrowserProxyImpl} from './os_settings_hats_browser_proxy.js';
import {getTemplate} from './os_settings_ui.html.js';

declare global {
  interface Window {
    settings: any;
  }
}

declare global {
  interface HTMLElementEventMap {
    'refresh-pref': CustomEvent<string>;
    'scroll-to-bottom': CustomEvent<{bottom: number, callback: () => void}>;
    'scroll-to-top': CustomEvent<{top: number, callback: () => void}>;
    'user-action-setting-change':
        CustomEvent<{prefKey: string, prefValue: any}>;
    'user-action-setting-pref-change': UserActionSettingPrefChangeEvent;
  }
}

/** Global defined when the main Settings script runs. */
let defaultResourceLoaded = true;  // eslint-disable-line prefer-const

assert(
    !window.settings || !defaultResourceLoaded,
    'os_settings_ui.js was executed twice. You probably have an invalid import.');

export interface OsSettingsUiElement {
  $: {
    container: HTMLDivElement,
    prefs: SettingsPrefsElement,
  };
}

const OsSettingsUiElementBase =
    // RouteObserverMixin calls currentRouteChanged() in
    // connectedCallback(), so ensure other mixins/behaviors run their
    // connectedCallback() first.
    RouteObserverMixin(
        FindShortcutMixin(CrContainerShadowMixin(PolymerElement)));

export class OsSettingsUiElement extends OsSettingsUiElementBase {
  static get is() {
    return 'os-settings-ui';
  }

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

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

      advancedOpenedInMain_: {
        type: Boolean,
        value: false,
        notify: true,
        observer: 'onAdvancedOpenedInMainChanged_',
      },

      advancedOpenedInMenu_: {
        type: Boolean,
        value: false,
        notify: true,
        observer: 'onAdvancedOpenedInMenuChanged_',
      },

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

      /**
       * Whether settings is in the narrow state (side nav hidden). Controlled
       * by a binding in the `settings-toolbar` element.
       */
      isNarrow: {
        type: Boolean,
        value: false,
        readonly: true,
        notify: true,
        observer: 'onNarrowChanged_',
      },

      /**
       * Used to determine which pages and menu items are available (not to be
       * confused with page visibility) to the user.
       * See os_page_availability.ts for more details.
       */
      pageAvailability_: {
        type: Object,
        value: () => {
          return createPageAvailability();
        },
      },

      showToolbar_: Boolean,

      showNavMenu_: Boolean,

      /**
       * The threshold at which the toolbar will change from normal to narrow
       * mode, in px.
       */
      narrowThreshold_: {
        type: Number,
        value: 980,
      },
    };
  }

  prefs: Object;
  isNarrow: boolean;
  private advancedOpenedInMain_: boolean;
  private advancedOpenedInMenu_: boolean;
  private toolbarSpinnerActive_: boolean;
  private pageAvailability_: OsPageAvailability;
  private showToolbar_: boolean;
  private showNavMenu_: boolean;
  private narrowThreshold_: number;
  private activeRoute_: Route|null;
  private scrollEndDebouncer_: Debouncer|null;
  private osSettingsHatsBrowserProxy_: OsSettingsHatsBrowserProxy;
  private boundTriggerSettingsHats_: () => void;
  private readonly isRevampWayfindingEnabled_: boolean =
      isRevampWayfindingEnabled();

  constructor() {
    super();

    /**
     * The route of the selected element in os-settings-menu. Stored here to
     * defer navigation until drawer animation completes.
     */
    this.activeRoute_ = null;

    this.scrollEndDebouncer_ = null;

    Router.getInstance().initializeRouteFromUrl();

    this.osSettingsHatsBrowserProxy_ =
        OsSettingsHatsBrowserProxyImpl.getInstance();

    this.boundTriggerSettingsHats_ = this.triggerSettingsHats_.bind(this);
  }

  override ready(): void {
    super.ready();

    window.CrPolicyStrings = {
      controlledSettingExtension:
          loadTimeData.getString('controlledSettingExtension'),
      controlledSettingExtensionWithoutName:
          loadTimeData.getString('controlledSettingExtensionWithoutName'),
      controlledSettingPolicy:
          loadTimeData.getString('controlledSettingPolicy'),
      controlledSettingRecommendedMatches:
          loadTimeData.getString('controlledSettingRecommendedMatches'),
      controlledSettingRecommendedDiffers:
          loadTimeData.getString('controlledSettingRecommendedDiffers'),
      controlledSettingShared:
          loadTimeData.getString('controlledSettingShared'),
      controlledSettingWithOwner:
          loadTimeData.getString('controlledSettingWithOwner'),
      controlledSettingNoOwner:
          loadTimeData.getString('controlledSettingNoOwner'),
      controlledSettingParent:
          loadTimeData.getString('controlledSettingParent'),
      controlledSettingChildRestriction:
          loadTimeData.getString('controlledSettingChildRestriction'),
    };

    this.showNavMenu_ = !loadTimeData.getBoolean('isKioskModeActive');
    this.showToolbar_ = !loadTimeData.getBoolean('isKioskModeActive');

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

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

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

    this.addEventListener('user-action-setting-pref-change', this.syncPrefChange_.bind(this));

    this.addEventListener('user-action-setting-change', this.recordChangedSetting_.bind(this));

    this.addEventListener(
        'search-changed',
        () => {
          this.osSettingsHatsBrowserProxy_.settingsUsedSearch();
        },
        /*AddEventListenerOptions=*/ {once: true});

    this.listenForDrawerOpening_();

    // By default, the shadow should show when the container is scrolled down.
    this.enableShadowBehavior(true);
  }

  override connectedCallback(): void {
    super.connectedCallback();

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

    setTimeout(() => {
      this.recordTimeUntilInteractive_();
    });

    // Preload bold Roboto so it doesn't load and flicker the first time used.
    document.fonts.load('bold 12px Roboto');
    setGlobalScrollTarget(this.$.container);

    const scrollToTop = (top: number): Promise<void> => new Promise(resolve => {
      if (this.$.container.scrollTop === top) {
        resolve();
        return;
      }

      this.$.container.scrollTo({top: top, behavior: 'auto'});
      const onScroll = (): void => {
        this.scrollEndDebouncer_ = Debouncer.debounce(
            this.scrollEndDebouncer_, timeOut.after(75), () => {
              this.$.container.removeEventListener('scroll', onScroll);
              resolve();
            });
      };
      this.$.container.addEventListener('scroll', onScroll);
    });
    this.addEventListener(
        'scroll-to-top',
        (e: CustomEvent<{top: number, callback: () => void}>) => {
          scrollToTop(e.detail.top).then(e.detail.callback);
        });
    this.addEventListener(
        'scroll-to-bottom',
        (e: CustomEvent<{bottom: number, callback: () => void}>) => {
          scrollToTop(e.detail.bottom - this.$.container.clientHeight)
              .then(e.detail.callback);
        });

    // Window event listeners will not fire when settings first starts.
    // Blur events before the first focus event do not matter.
    if (document.hasFocus()) {
      recordPageFocus();
    }

    window.addEventListener('focus', recordPageFocus);
    window.addEventListener('blur', recordPageBlur);

    window.addEventListener('blur', this.boundTriggerSettingsHats_);

    // Clicks need to be captured because unlike focus/blur to the settings
    // window, a click's propagation can be stopped by child elements.
    window.addEventListener('click', recordClick, /*capture=*/ true);

    if (this.isRevampWayfindingEnabled_) {
      // Add class which activates styles for the wayfinding update
      document.body.classList.add('revamp-wayfinding-enabled');
    }
  }

  override disconnectedCallback(): void {
    super.disconnectedCallback();

    window.removeEventListener('focus', recordPageFocus);
    window.removeEventListener('blur', recordPageBlur);
    window.removeEventListener('blur', this.boundTriggerSettingsHats_);
    window.removeEventListener('click', recordClick);
    Router.getInstance().resetRouteForTesting();
  }

  override currentRouteChanged(newRoute: Route, oldRoute?: Route): void {
    if (oldRoute && newRoute !== oldRoute) {
      // Search triggers route changes and currentRouteChanged() is called
      // in attached() state which is extraneous for this metric.
      recordNavigation();
    }

    // TODO(b/302374851) Under the revamp, the shadow behavior is consistent
    // across all types of pages and subpages. When the revamp is cleaned up,
    // remove this obsolete logic.
    if (!this.isRevampWayfindingEnabled_) {
      if (newRoute.isSubpage()) {
        // Sub-pages always show the top-container shadow.
        this.enableShadowBehavior(false);
        this.showDropShadows();
      } else {
        // All other pages including the root page should show shadow depending
        // on scroll position.
        this.enableShadowBehavior(true);
      }
    }
  }

  // Override FindShortcutMixin methods.
  override handleFindShortcut(modalContextOpen: boolean): boolean {
    if (modalContextOpen || !this.showToolbar_) {
      return false;
    }
    const toolbar = this.getToolbar_();
    toolbar.getSearchField().showAndFocus();
    toolbar.getSearchField().getSearchInput().select();
    return true;
  }

  // Override FindShortcutMixin methods.
  override searchInputHasFocus(): boolean {
    if (!this.showToolbar_) {
      return false;
    }

    return this.getToolbar_().getSearchField().isSearchFocused();
  }

  /**
   * Listen for the drawer opening event and lazily create the drawer the first
   * time it is opened or swiped into view.
   */
  private listenForDrawerOpening_(): void {
    // If navigation menu is not shown, do not listen for the drawer opening
    if (!this.showNavMenu_) {
      return;
    }

    microTask.run(() => {
      const drawer = this.getDrawer_();
      listenOnce(drawer, 'cr-drawer-opening', () => {
        const drawerTemplate = castExists(
            this.shadowRoot!.querySelector<DomIf>('#drawerTemplate'));
        drawerTemplate.if = true;
      });

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

  private getDrawer_(): CrDrawerElement {
    return castExists(this.shadowRoot!.querySelector('cr-drawer'));
  }

  private getToolbar_(): SettingsToolbarElement {
    return castExists(this.shadowRoot!.querySelector('settings-toolbar'));
  }

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

  /**
   * Callback for the `user-action-setting-change` event which is emitted by
   * the `settings-prefs` singleton after a pref-based setting is updated via
   * some user action. Records the changed setting to relevant metrics.
   */
  private recordChangedSetting_(e: CustomEvent<{prefKey: string, prefValue: any}>):
      void {
    const {prefKey, prefValue} = e.detail;
    const settingMetric = convertPrefToSettingMetric(prefKey, prefValue);

    // New metrics for this setting pref have not yet been implemented.
    if (!settingMetric) {
      recordSettingChangeForUnmappedPref();
      return;
    }

    recordSettingChange(settingMetric.setting, settingMetric.value);
  }

  /**
   * Callback for the `user-action-setting-pref-change` event which is emitted
   * by settings pref control components when the prefs state should be synced
   * after some user action (e.g. a toggle was turned on). Updates the prefs
   * state and syncs it with the `settings-prefs` singleton, which applies the
   * update at the OS level.
   */
  private syncPrefChange_(event: UserActionSettingPrefChangeEvent): void {
    const {prefKey, value} = event.detail;
    this.set(`prefs.${prefKey}.value`, value);
  }

  /**
   * Called when a menu item is selected.
   */
  private onMenuItemSelected_(e: CustomEvent<{selected: string}>): void {
    assert(this.showNavMenu_);
    const path = e.detail.selected;
    const route = Router.getInstance().getRouteForPath(path);
    assert(route, `os-settings-menu-item with invalid route: ${path}`);
    this.activeRoute_ = route;

    if (this.isNarrow) {
      // If the onIronActivate event came from the drawer, close the drawer
      // and wait for the menu to close before navigating to |activeRoute_|.
      this.getDrawer_().close();
      return;
    }
    this.navigateToActiveRoute_();
  }

  private onMenuButtonClick_(): void {
    if (!this.showNavMenu_) {
      return;
    }
    this.getDrawer_().toggle();
  }

  /**
   * Navigates to |activeRoute_| if set. Used to delay navigation until after
   * animations complete to ensure focus ends up in the right place.
   */
  private navigateToActiveRoute_(): void {
    if (this.activeRoute_) {
      Router.getInstance().navigateTo(
          this.activeRoute_, /* dynamicParams */ undefined,
          /* removeSearch */ true);
      this.activeRoute_ = null;
    }
  }

  /**
   * 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_(): void {
    if (!this.getDrawer_().wasCanceled()) {
      // If a navigation happened, MainPageMixin#currentRouteChanged
      // handles focusing the corresponding section when we call
      // settings.NavigateTo().
      this.navigateToActiveRoute_();
      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 onAdvancedOpenedInMainChanged_(): void {
    // Only sync value when opening, not closing.
    if (this.advancedOpenedInMain_) {
      this.advancedOpenedInMenu_ = true;
    }
  }

  private onAdvancedOpenedInMenuChanged_(): void {
    // Only sync value when opening, not closing.
    if (this.advancedOpenedInMenu_) {
      this.advancedOpenedInMain_ = true;
    }
  }

  private onNarrowChanged_(): void {
    if (this.showNavMenu_) {
      const drawer = this.getDrawer_();
      if (drawer.open && !this.isNarrow) {
        drawer.close();
      }
    }
  }

  /**
   * Handles a tap on the drawer's icon.
   */
  private onDrawerIconClick_(): void {
    this.getDrawer_().cancel();
  }

  private recordTimeUntilInteractive_(): void {
    const METRIC_NAME = 'ChromeOS.Settings.TimeUntilInteractive';
    const timeMs = Math.round(window.performance.now());
    chrome.metricsPrivate.recordTime(METRIC_NAME, timeMs);
  }

  private triggerSettingsHats_(): void {
    this.osSettingsHatsBrowserProxy_.sendSettingsHats();
  }
}

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

customElements.define(OsSettingsUiElement.is, OsSettingsUiElement);