chromium/ash/webui/shortcut_customization_ui/resources/js/shortcuts_page.ts

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

import './accelerator_subsection.js';
import '../css/shortcut_customization_shared.css.js';

import {strictQuery} from 'chrome://resources/ash/common/typescript_utils/strict_query.js';
import {assert} from 'chrome://resources/js/assert.js';
import {PolymerElementProperties} from 'chrome://resources/polymer/v3_0/polymer/interfaces.js';
import {afterNextRender, microTask, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {AcceleratorLookupManager} from './accelerator_lookup_manager.js';
import {AcceleratorRowElement} from './accelerator_row.js';
import {AcceleratorSubsectionElement} from './accelerator_subsection.js';
import {getShortcutProvider} from './mojo_interface_provider.js';
import {RouteObserver, Router} from './router.js';
import {AcceleratorCategory, AcceleratorSubcategory} from './shortcut_types.js';
import {getTemplate} from './shortcuts_page.html.js';

/**
 * @fileoverview
 * 'shortcuts-page' is a generic page that is capable of rendering the
 * shortcuts for a specific category.
 */

// 150ms is enough of delay to wait for the virtual keyboard to disappear and
// resume with a smooth scroll.
const kDefaultScrollTimeout = 150;

export class ShortcutsPageElement extends PolymerElement implements
    RouteObserver {
  static get is(): string {
    return 'shortcuts-page';
  }

  static get properties(): PolymerElementProperties {
    return {
      /**
       * Implicit property from NavigationSelector. Contains one Number field,
       * |category|, that holds the category type of this shortcut page.
       */
      initialData: {
        type: Object,
      },

      subcategories: {
        type: Array,
        value: [],
      },
    };
  }

  initialData: {category: AcceleratorCategory}|null;
  subcategories: AcceleratorSubcategory[];
  private scrollTimeout: number = kDefaultScrollTimeout;
  private lookupManager: AcceleratorLookupManager =
      AcceleratorLookupManager.getInstance();

  override connectedCallback(): void {
    super.connectedCallback();
    this.updateAccelerators();
    Router.getInstance().addObserver(this);
  }

  override disconnectedCallback(): void {
    super.disconnectedCallback();
    Router.getInstance().removeObserver(this);
  }

  updateAccelerators(): void {
    if (!this.initialData) {
      return;
    }

    const subcatMap =
        this.lookupManager.getSubcategories(this.initialData.category);
    if (subcatMap === undefined) {
      return;
    }

    const subcategories: number[] = [];
    for (const key of subcatMap.keys()) {
      subcategories.push(key);
    }
    this.subcategories = subcategories;
  }

  private getAllSubsections(): NodeListOf<AcceleratorSubsectionElement> {
    const subsections =
        this.shadowRoot!.querySelectorAll('accelerator-subsection');
    assert(subsections);
    return subsections;
  }

  updateSubsections(): void {
    for (const subsection of this.getAllSubsections()) {
      subsection.updateSubsection();
    }
  }

  /**
   * 'navigation-view-panel' is responsible for calling this function when
   * the active page changes.
   * @param isActive True if this page is the new active page.
   */
  onNavigationPageChanged({isActive}: {isActive: boolean}): void {
    if (isActive) {
      afterNextRender(this, () => {
        if (this.initialData) {
          getShortcutProvider().recordMainCategoryNavigation(
              this.initialData.category);
        }
        // Dispatch a custom event to inform the parent to scroll to the top
        // after active page changes.
        this.dispatchEvent(new CustomEvent('scroll-to-top', {
          bubbles: true,
          composed: true,
        }));

        // Scroll to the specific accelerator if this page change was caused by
        // clicking on a search result. If the page change was manual, the
        // method below will be a no-op.
        const didScroll = this.maybeScrollToAcceleratorRowBasedOnUrl(
            new URL(window.location.href));

        if (didScroll) {
          // Reset the route after scrolling so the app doesn't re-scroll when
          // the user manually changes pages.
          Router.getInstance().resetRoute();
        }
      });
    }
  }

  /**
   * This method is called by the Router when the URL is updated via
   * the Router's `navigateTo` method.
   *
   * For this element, listening to route changes allows it to potentially
   * scroll to one of its child accelerators if the URL contains the correct
   * search params. This will happen if the route change was caused by selecting
   * a search result.
   */
  onRouteChanged(url: URL): void {
    const didScroll = this.maybeScrollToAcceleratorRowBasedOnUrl(url);
    if (didScroll) {
      // Reset the route after scrolling so the app doesn't re-scroll when
      // the user manually changes pages.
      Router.getInstance().resetRoute();
    }
  }

  /**
   * Scroll the URL-selected accelerator to the top of the page.
   * If the URL does not contain the correct search params (`action` and
   * `category`), then this method is a no-op.
   * @returns True if the scroll event happened.
   */
  private maybeScrollToAcceleratorRowBasedOnUrl(url: URL): boolean {
    const action = url.searchParams.get('action');
    const category = url.searchParams.get('category');
    if (!action || !category) {
      // This route change did not include the params that would trigger a
      // scroll event.
      return false;
    }

    if (this.initialData?.category.toString() !== category) {
      // Only focus the element if we're in the correct category.
      return false;
    }

    const acceleratorSubsections =
        this.shadowRoot?.querySelectorAll<AcceleratorSubsectionElement>(
            'accelerator-subsection');
    assert(
        acceleratorSubsections,
        'Expected this element to contain accelerator-subsection elements.');

    for (let i = 0; i < acceleratorSubsections.length; i++) {
      const matchingAcceleratorRow =
          acceleratorSubsections[i]
              .shadowRoot?.querySelector<AcceleratorRowElement>(
                  `accelerator-row[action="${action}"]`);
      if (matchingAcceleratorRow) {
        // Use microtask timing to ensure that the scrolling action happens.
        microTask.run(() => {
          // Note: There is a bug in which the onscreen virtual keyboard's close
          // animation conflicts with smooth scrolling. Adding a 150ms handles
          // the issue by waiting for the virtual keyboard close animation
          // to finish.
          if (this.scrollTimeout === 0) {
            // Don't queue a timeout if there is no delay to be added. A zero
            // timeout will still make the function an asynchronous call.
            // This removes the need to use a flaky timeout for tests.
            matchingAcceleratorRow.scrollIntoView({behavior: 'smooth'});
          } else {
            setTimeout(() => {
              matchingAcceleratorRow.scrollIntoView({behavior: 'smooth'});
            }, this.scrollTimeout);
          }
        });

        // Focus on the matching accelerator row.
        strictQuery(
            '#container', matchingAcceleratorRow.shadowRoot,
            HTMLTableRowElement)
            .focus();
        this.lookupManager.setSearchResultRowFocused(true);

        // The scroll event did happen, so return true.
        return true;
      }
    }

    return false;
  }

  setScrollTimeoutForTesting(timeout: number): void {
    this.scrollTimeout = timeout;
  }

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

customElements.define(ShortcutsPageElement.is, ShortcutsPageElement);