chromium/ash/webui/shortcut_customization_ui/resources/js/search/search_box.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.

import 'chrome://resources/ash/common/cr_elements/cros_color_overrides.css.js';
import 'chrome://resources/ash/common/cr_elements/cr_toolbar/cr_toolbar_search_field.js';
import './search_result_row.js';
import 'chrome://resources/polymer/v3_0/iron-dropdown/iron-dropdown.js';
import 'chrome://resources/polymer/v3_0/iron-list/iron-list.js';

import {strictQuery} from 'chrome://resources/ash/common/typescript_utils/strict_query.js';
import {getInstance as getAnnouncerInstance} from 'chrome://resources/ash/common/cr_elements/cr_a11y_announcer/cr_a11y_announcer.js';
import {CrToolbarSearchFieldElement} from 'chrome://resources/ash/common/cr_elements/cr_toolbar/cr_toolbar_search_field.js';
import {I18nMixin} from 'chrome://resources/ash/common/cr_elements/i18n_mixin.js';
import {assert} from 'chrome://resources/js/assert.js';
import {stringToMojoString16} from 'chrome://resources/js/mojo_type_util.js';
import {IronDropdownElement} from 'chrome://resources/polymer/v3_0/iron-dropdown/iron-dropdown.js';
import {IronListElement} from 'chrome://resources/polymer/v3_0/iron-list/iron-list.js';
import {PolymerElementProperties} from 'chrome://resources/polymer/v3_0/polymer/interfaces.js';
import {afterNextRender, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {SearchResultsAvailabilityObserverInterface, SearchResultsAvailabilityObserverReceiver} from '../../mojom-webui/search.mojom-webui.js';
import {AcceleratorState, MojoSearchResult, ShortcutSearchHandlerInterface} from '../shortcut_types.js';
import {isCustomizationAllowed} from '../shortcut_utils.js';

import {getTemplate} from './search_box.html.js';
import {SearchResultRowElement} from './search_result_row.js';
import {getShortcutSearchHandler} from './shortcut_search_handler.js';

/**
 * @fileoverview
 * 'search-box' is the container for the search input and shortcut search
 * results.
 */

const MAX_NUM_RESULTS = 5;
// This number was chosen arbitrarily to be a reasonable limit. Most
// searches will not be anywhere close to this.
const MAX_QUERY_LENGTH_CHARACTERS = 200;

const SearchBoxElementBase = I18nMixin(PolymerElement);

export class SearchBoxElement extends SearchBoxElementBase implements
    SearchResultsAvailabilityObserverInterface {
  static get is(): string {
    return 'search-box';
  }

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

  static get properties(): PolymerElementProperties {
    return {
      searchResults: {
        type: Array,
        value: [],
        observer: SearchBoxElement.prototype.onSearchResultsChanged,
      },

      shouldShowDropdown: {
        type: Boolean,
        value: false,
        reflectToAttribute: true,
      },

      searchResultsExist: {
        type: Boolean,
        value: false,
        computed: 'computeSearchResultsExist(searchResults)',
      },

      hasSearchQuery: {
        type: Boolean,
        value: false,
        reflectToAttribute: true,
      },

      /**
       * The currently selected search result associated with a
       * <search-result-row>. This property is bound to the <iron-list>. Note
       * that when an item is selected, its associated <search-result-row>
       * is not focus()ed at the same time unless it is explicitly
       * clicked/tapped.
       */
      selectedItem: {
        type: Object,
      },

      /**
       * Used by FocusRowMixin to track the last focused element inside a
       * <search-result-row> with the attribute 'focus-row-control'.
       */
      lastFocused: Object,

      /**
       * Used by FocusRowMixin to track if the list has been blurred.
       */
      listBlurred: Boolean,

      /**
       * Value is proxied through to cr-toolbar-search-field. When true, the
       * search field will show a processing spinner.
       */
      spinnerActive: Boolean,
    };
  }

  hasSearchQuery: boolean;
  searchResults: MojoSearchResult[];
  shouldShowDropdown: boolean;
  private lastFocused: HTMLElement|null;
  private listBlurred: boolean;
  private resizeObserver: ResizeObserver;
  private searchInputElement: HTMLInputElement;
  private searchResultsExist: boolean;
  private selectedItem: MojoSearchResult;
  private shortcutSearchHandler: ShortcutSearchHandlerInterface;
  private spinnerActive: boolean;

  constructor() {
    super();
    this.shortcutSearchHandler = getShortcutSearchHandler();
    const receiver = new SearchResultsAvailabilityObserverReceiver(this);
    this.shortcutSearchHandler.addSearchResultsAvailabilityObserver(
        receiver.$.bindNewPipeAndPassRemote());
  }

  onSearchResultsAvailabilityChanged(): void {
    this.onSearchChanged();
  }

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

    this.addEventListener('blur', this.onBlur);
    this.addEventListener('keydown', this.onKeyDown);
    // This event is fired (after a short debounce) from the
    // cr-toolbar-search-field when the input changes.
    this.addEventListener('search-changed', this.onSearchChanged);
  }

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

    const searchFieldElement =
        strictQuery('#search', this.shadowRoot, CrToolbarSearchFieldElement);
    searchFieldElement.addEventListener(
        'transitionend', this.onSearchFieldTransitionEnd.bind(this));

    this.searchInputElement = searchFieldElement.getSearchInput();

    // Focus the search bar when the app opens.
    afterNextRender(this, () => {
      this.searchInputElement.focus();
    });

    this.searchInputElement.addEventListener(
        'focus', this.onSearchInputFocused.bind(this));
    this.searchInputElement.addEventListener(
        'mousedown', this.onSearchInputMousedown.bind(this));

    this.searchInputElement.maxLength = MAX_QUERY_LENGTH_CHARACTERS;

    // This is a required work around to get the iron-list to display correctly
    // on the first search query. Currently iron-list won't generate item
    // elements on attach if the element is not visible. To work around this, we
    // listen for resize events and manually call notifyResize on the iron-list
    // when the iron-dropdown state changes.
    this.resizeObserver = new ResizeObserver(() => {
      const ironListElement =
          (this.shadowRoot?.querySelector('iron-list') as IronListElement);
      if (ironListElement) {
        ironListElement.notifyResize();
      }
    });
    this.resizeObserver.observe(
        strictQuery('iron-dropdown', this.shadowRoot, HTMLElement));
  }

  override disconnectedCallback(): void {
    super.disconnectedCallback();
    this.resizeObserver.disconnect();
  }

  private onBlur(event: UIEvent): void {
    event.stopPropagation();
    // Close the dropdown because a region outside the search box was clicked.
    this.shouldShowDropdown = false;
  }

  private computeSearchResultsExist(): boolean {
    return this.searchResults.length !== 0;
  }

  /**
   * @return Length of the search results array.
   */
  private getListLength(): number {
    return this.searchResults.length;
  }

  private getCurrentQuery(): string {
    return this.searchInputElement.value;
  }

  private onSearchChanged(): void {
    this.hasSearchQuery = !!this.getCurrentQuery();
    if (!this.hasSearchQuery) {
      // Cancel the spinner if the current query is empty to avoid a rare case
      // where the spinner stays active forever.
      this.spinnerActive = false;
    }
    this.fetchSearchResults(this.getCurrentQuery());
  }

  /**
   * Returns the correct tab index since <iron-list>'s default tabIndex property
   * does not automatically add selectedItem's <search-result-row> to the
   * default navigation flow, unless the user explicitly clicks on the row.
   * @param item The search result item in searchResults.
   * @return A 0 if the row should be in the navigation flow, or a -1
   *     if the row should not be in the navigation flow.
   */
  private getRowTabIndex(item: MojoSearchResult): number {
    return this.isItemSelected(item) && this.shouldShowDropdown ? 0 : -1;
  }

  private onSearchIconClicked(): void {
    // Select the query text.
    this.searchInputElement.select();

    if (this.getCurrentQuery()) {
      this.shouldShowDropdown = true;
    }
  }

  private onSearchInputFocused(): void {
    if (this.searchResultsExist) {
      // Restore previous results instead of re-fetching.
      this.shouldShowDropdown = true;
      return;
    }

    this.fetchSearchResults(this.getCurrentQuery());
  }

  private onSearchInputMousedown(): void {
    // If the search input is clicked while the dropdown is closed, and there
    // already contains input text from a previous query, highlight the entire
    // query text so that the user can choose to easily replace the query
    // instead of having to delete the previous query manually. A mousedown
    // event is used because it is captured before |shouldShowDropdown|
    // changes, unlike a click event which is captured after
    // |shouldShowDropdown| changes.
    if (!this.shouldShowDropdown) {
      // Select all search input text once the initial state is set.
      afterNextRender(this, () => this.searchInputElement.select());
    }
  }

  private onSearchFieldTransitionEnd(): void {
    // Cast to IronDropdownElement since the interface cannot be used as a
    // value.
    const ironDropdown =
        (strictQuery('iron-dropdown', this.shadowRoot, HTMLElement) as
         IronDropdownElement);

    // Resize the dropdown once the search bar has finishing resizing to avoid
    // misalignment when the window resizes.
    ironDropdown.notifyResize();
  }

  private onKeyDown(e: KeyboardEvent): void {
    const isSearchFocused =
        strictQuery('#search', this.shadowRoot, CrToolbarSearchFieldElement)
            .isSearchFocused();
    if (!this.searchResultsExist || !(isSearchFocused || this.lastFocused)) {
      // No action should be taken if there are no search results, or when
      // neither the search input nor a <search-result-row> is focused
      // (ChromeVox may focus on clear search input button).
      return;
    }

    // Press enter to navigate to the selected search result.
    // Check that a selected search result exists first, since it's possible for
    // the user to press enter before the iron-list is fully rendered.
    if (e.key === 'Enter' && this.hasSelectedSearchResultRow()) {
      this.getSelectedSearchResultRow().onSearchResultSelected();
      return;
    }

    if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
      // Do not impact the position of <cr-toolbar-search-field>'s caret.
      e.preventDefault();
      this.selectRowViaKeys(e.key);
      return;
    }
  }

  private onSearchResultsChanged(): void {
    // Select the first search result if it exists.
    if (this.searchResultsExist) {
      this.selectedItem = this.searchResults[0];
    }

    // Only show dropdown if focus is on search field with a non empty query.
    this.shouldShowDropdown =
        strictQuery('#search', this.shadowRoot, CrToolbarSearchFieldElement)
            .isSearchFocused() &&
        !!this.getCurrentQuery();

    if (this.shouldShowDropdown && !this.searchResultsExist) {
      getAnnouncerInstance().announce(this.i18n('searchNoResults'));
    }
  }

  private onNavigatedToResultRowRoute(): void {
    // Blur search input to prevent blinking caret. Note that this blur event
    // will not always be propagated to the SearchBoxElement (e.g. user decides
    // to click on the same search result twice) so |this.shouldShowDropdown|
    // must always be set to false in |this.onNavigatedToResultRowRoute()|.
    strictQuery('#search', this.shadowRoot, CrToolbarSearchFieldElement).blur();

    // Shortcuts has navigated to another page; close search results dropdown.
    this.shouldShowDropdown = false;
  }

  /**
   * @param item The search result item in searchResults.
   * @return True if the item is selected.
   */
  private isItemSelected(item: MojoSearchResult): boolean {
    return this.searchResults.indexOf(item) ===
        this.searchResults.indexOf(this.selectedItem);
  }

  /**
   * @return True if there is a selected <search-result-row> element.
   */
  private hasSelectedSearchResultRow(): boolean {
    return !!this.shadowRoot?.querySelector('search-result-row[selected]');
  }

  /**
   * @return The <search-result-row> that is associated with the selectedItem.
   */
  private getSelectedSearchResultRow(): SearchResultRowElement {
    return strictQuery(
        'search-result-row[selected]',
        strictQuery('#searchResultList', this.shadowRoot, HTMLElement),
        SearchResultRowElement);
  }

  /**
   * @param key The string associated with a key.
   */
  private selectRowViaKeys(key: string): void {
    assert(key === 'ArrowDown' || key === 'ArrowUp', 'Only arrow keys.');
    assert(!!this.selectedItem, 'There should be a selected item already.');

    // Select the new item.
    const selectedRowIndex = this.searchResults.indexOf(this.selectedItem);
    const numRows = this.searchResults.length;
    const delta = key === 'ArrowUp' ? -1 : 1;
    const indexOfNewRow = (numRows + selectedRowIndex + delta) % numRows;
    this.selectedItem = this.searchResults[indexOfNewRow];

    // If this.lastFocused is truthy, that means a row was previously focused.
    if (this.lastFocused) {
      // If a row was previously focused, focus the currently selected row.
      // Calling focus() on a <search-result-row> focuses the element within
      // containing the attribute 'focus-row-control'.
      this.getSelectedSearchResultRow().focus();
    }

    // TODO(cambickel): Scroll into view if needed.
    // The newly selected item might not be visible because the list needs
    // to be scrolled. So scroll the dropdown if necessary.
  }

  private fetchSearchResults(query: string): void {
    if (query === '') {
      this.searchResults = [];
      return;
    }

    this.spinnerActive = true;

    // In some cases, the backend will return search results that are later
    // filtered out by `this.filterSearchResults`. When that happens, the UI
    // should still show MAX_NUM_RESULTS results if there are other matching
    // results. To achieve this, we request more results than we need, and then
    // cap the number of search results to MAX_NUM_RESULTS.
    const maxNumberOfSearchResults = MAX_NUM_RESULTS * 3;

    this.shortcutSearchHandler
        .search(stringToMojoString16(query), maxNumberOfSearchResults)
        .then((response) => {
          this.onSearchResultsReceived(query, response.results);
          this.dispatchEvent(new CustomEvent(
              'search-results-fetched', {bubbles: true, composed: true}));
        });
  }

  private onSearchResultsReceived(query: string, results: MojoSearchResult[]):
      void {
    if (query !== this.getCurrentQuery()) {
      // Received search results are invalid as the query has since changed.
      return;
    }

    this.spinnerActive = false;

    this.searchResults = this.filterSearchResults(results);

    // In `this.fetchSearchResults`, we queried for a multiple of
    // MAX_NUM_RESULTS, so cap the size of the results here after filtering.
    this.searchResults = this.searchResults.slice(0, MAX_NUM_RESULTS);

    // This invalidates whatever SearchResultRow element was previously focused,
    // since it's likely that the element has been removed after the search.
    this.lastFocused = null;
  }

  /**
   * Filter the given search results to hide accelerators and results that are
   * disabled because their keys are unavailable or they are disabled by user.
   * This filtering matches the behavior of the Shortcut app's main list of
   * shortcuts.
   * @param searchResults the search results to filter.
   * @returns the given search results with disabled keys and results with no
   *     keys filtered out.
   */
  private filterSearchResults(searchResults: MojoSearchResult[]):
      MojoSearchResult[] {
    const enabledSearchResults =
        searchResults
            // Hide accelerators that are disabled because the keys are
            // unavailable.
            .map(result => ({
                   ...result,
                   acceleratorInfos: result.acceleratorInfos.filter(
                       a => a.state !==
                               AcceleratorState.kDisabledByUnavailableKeys &&
                           a.state !== AcceleratorState.kDisabledByUser),
                 }));

    // If customization is not allowed, hide results that don't contain any
    // accelerators.
    if (!isCustomizationAllowed()) {
      return enabledSearchResults.filter(
          result => result.acceleratorInfos.length > 0);
    }

    return enabledSearchResults;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'search-box': SearchBoxElement;
  }
}

customElements.define(SearchBoxElement.is, SearchBoxElement);