chromium/ui/webui/resources/cr_components/searchbox/searchbox_dropdown.ts

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

import './searchbox_match.js';
import './searchbox_dropdown_shared_style.css.js';
import '//resources/polymer/v3_0/iron-selector/iron-selector.js';
import '//resources/cr_elements/cr_icon_button/cr_icon_button.js';
import '//resources/cr_elements/cr_icons.css.js';

import {loadTimeData} from '//resources/js/load_time_data.js';
import {MetricsReporterImpl} from '//resources/js/metrics_reporter/metrics_reporter.js';
import {PolymerElement} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {SearchboxBrowserProxy} from './searchbox_browser_proxy.js';
import {getTemplate} from './searchbox_dropdown.html.js';
import type {SearchboxMatchElement} from './searchbox_match.js';
import type {AutocompleteMatch, AutocompleteResult, OmniboxPopupSelection, PageHandlerInterface} from './searchbox.mojom-webui.js';
import {RenderType, SelectionLineState, SideType} from './searchbox.mojom-webui.js';
import {decodeString16, renderTypeToClass, sideTypeToClass} from './utils.js';

// The '%' operator in JS returns negative numbers. This workaround avoids that.
const remainder = (lhs: number, rhs: number) => ((lhs % rhs) + rhs) % rhs;

const CHAR_TYPED_TO_PAINT = 'Realbox.CharTypedToRepaintLatency.ToPaint';
const RESULT_CHANGED_TO_PAINT = 'Realbox.ResultChangedToRepaintLatency.ToPaint';

export interface SearchboxDropdownElement {
  $: {
    content: HTMLElement,
  };
}

// A dropdown element that contains autocomplete matches. Provides an API for
// the embedder (i.e., <cr-searchbox>) to change the selection.
export class SearchboxDropdownElement extends PolymerElement {
  static get is() {
    return 'cr-searchbox-dropdown';
  }

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

  static get properties() {
    return {
      //========================================================================
      // Public properties
      //========================================================================

      /**
       * Whether the secondary side can be shown based on the feature state and
       * the width available to the dropdown.
       */
      canShowSecondarySide: {
        type: Boolean,
        value: false,
      },

      /**
       * Whether the secondary side was at any point available to be shown.
       */
      hadSecondarySide: {
        type: Boolean,
        value: false,
        notify: true,
      },

      /*
       * Whether the secondary side is currently available to be shown.
       */
      hasSecondarySide: {
        type: Boolean,
        computed: `computeHasSecondarySide_(result)`,
        notify: true,
        reflectToAttribute: true,
      },

      hasEmptyInput: {
        type: Boolean,
        reflectToAttribute: true,
        computed: `computeHasEmptyInput_(result)`,
      },

      result: {
        type: Object,
      },

      /** Index of the selected match. */
      selectedMatchIndex: {
        type: Number,
        value: -1,
        notify: true,
      },

      /**
       * Computed value for whether or not the dropdown should show the
       * secondary side. This depends on whether the parent has set
       * `canShowSecondarySide` to true and whether there are visible primary
       * matches.
       */
      showSecondarySide_: {
        type: Boolean,
        value: false,
        computed: 'computeShowSecondarySide_(' +
            'canShowSecondarySide, result.matches.*, hiddenGroupIds_.*)',
      },

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

      //========================================================================
      // Private properties
      //========================================================================

      /** The list of suggestion group IDs whose matches should be hidden. */
      hiddenGroupIds_: {
        type: Array,
        computed: `computeHiddenGroupIds_(result)`,
      },

      /** The list of selectable match elements. */
      selectableMatchElements_: {
        type: Array,
        value: () => [],
      },
    };
  }

  canShowSecondarySide: boolean;
  hadSecondarySide: boolean;
  hasSecondarySide: boolean;
  result: AutocompleteResult;
  selectedMatchIndex: number;
  private hiddenGroupIds_: number[];
  private selectableMatchElements_: SearchboxMatchElement[];
  private showSecondarySide_: boolean;
  private resizeObserver_: ResizeObserver|null = null;
  private pageHandler_: PageHandlerInterface;

  constructor() {
    super();
    this.pageHandler_ = SearchboxBrowserProxy.getInstance().handler;
  }

  override connectedCallback() {
    super.connectedCallback();
    this.resizeObserver_ = new ResizeObserver(
        (entries: ResizeObserverEntry[]) =>
            this.pageHandler_.popupElementSizeChanged({
              width: entries[0].contentRect.width,
              height: entries[0].contentRect.height,
            }));
    this.resizeObserver_.observe(this.$.content);
  }

  override disconnectedCallback() {
    if (this.resizeObserver_) {
      this.resizeObserver_.disconnect();
    }
    super.disconnectedCallback();
  }

  //============================================================================
  // Public methods
  //============================================================================

  /** Filters out secondary matches, if any, unless they can be shown. */
  get selectableMatchElements() {
    return this.selectableMatchElements_.filter(
        matchEl => matchEl.sideType === SideType.kDefaultPrimary ||
            this.showSecondarySide_);
  }

  /** Unselects the currently selected match, if any. */
  unselect() {
    this.selectedMatchIndex = -1;
  }

  /** Focuses the selected match, if any. */
  focusSelected() {
    this.selectableMatchElements[this.selectedMatchIndex]?.focus();
  }

  /** Selects the first match. */
  selectFirst() {
    this.selectedMatchIndex = 0;
  }

  /** Selects the match at the given index. */
  selectIndex(index: number) {
    this.selectedMatchIndex = index;
  }

  updateSelection(
      oldSelection: OmniboxPopupSelection, selection: OmniboxPopupSelection) {
    if (selection.state === SelectionLineState.kFocusedButtonHeader) {
      // TODO: Focus group header.
      this.unselect();
      return;
    }
    // If the updated selection is a new match, remove any remaining selection
    // on the previously selected match.
    if (oldSelection.line !== selection.line) {
      this.selectableMatchElements[this.selectedMatchIndex]?.updateSelection(
          selection);
    }
    this.selectIndex(selection.line);
    this.selectableMatchElements[this.selectedMatchIndex]?.updateSelection(
        selection);
  }

  /**
   * Selects the previous match with respect to the currently selected one.
   * Selects the last match if the first one or no match is currently selected.
   */
  selectPrevious() {
    // The value of -1 for |this.selectedMatchIndex| indicates no selection.
    // Therefore subtract one from the maximum of its value and 0.
    const previous = Math.max(this.selectedMatchIndex, 0) - 1;
    this.selectedMatchIndex =
        remainder(previous, this.selectableMatchElements.length);
  }

  /** Selects the last match. */
  selectLast() {
    this.selectedMatchIndex = this.selectableMatchElements.length - 1;
  }

  /**
   * Selects the next match with respect to the currently selected one.
   * Selects the first match if the last one or no match is currently selected.
   */
  selectNext() {
    const next = this.selectedMatchIndex + 1;
    this.selectedMatchIndex =
        remainder(next, this.selectableMatchElements.length);
  }

  //============================================================================
  // Event handlers
  //============================================================================

  private onHeaderClick_(e: Event) {
    const groupId =
        Number.parseInt((e.currentTarget as HTMLElement).dataset['id']!, 10);

    // Tell the backend to toggle visibility of the given suggestion group ID.
    this.pageHandler_.toggleSuggestionGroupIdVisibility(groupId);

    // Hide/Show matches with the given suggestion group ID.
    const index = this.hiddenGroupIds_.indexOf(groupId);
    if (index === -1) {
      this.push('hiddenGroupIds_', groupId);
    } else {
      this.splice('hiddenGroupIds_', index, 1);
    }
  }

  private onHeaderFocusin_() {
    this.dispatchEvent(new CustomEvent('header-focusin', {
      bubbles: true,
      composed: true,
    }));
  }

  private onHeaderMousedown_(e: Event) {
    e.preventDefault();  // Prevents default browser action (focus).
  }

  private onResultRepaint_() {
    if (loadTimeData.getBoolean('reportMetrics')) {
      const metricsReporter = MetricsReporterImpl.getInstance();
      metricsReporter.measure('CharTyped')
          .then(duration => {
            metricsReporter.umaReportTime(CHAR_TYPED_TO_PAINT, duration);
          })
          .then(() => {
            metricsReporter.clearMark('CharTyped');
          })
          .catch(() => {});  // Fail silently if 'CharTyped' is not marked.

      metricsReporter.measure('ResultChanged')
          .then(duration => {
            metricsReporter.umaReportTime(RESULT_CHANGED_TO_PAINT, duration);
          })
          .then(() => {
            metricsReporter.clearMark('ResultChanged');
          })
          .catch(() => {});  // Fail silently if 'ResultChanged' is not marked.
    }

    // Update the list of selectable match elements.
    this.selectableMatchElements_ =
        [...this.shadowRoot!.querySelectorAll('cr-searchbox-match')];
  }

  //============================================================================
  // Helpers
  //============================================================================

  private sideTypeClass_(side: SideType): string {
    return sideTypeToClass(side);
  }

  private renderTypeClassForGroup_(groupId: number): string {
    return renderTypeToClass(
        this.result?.suggestionGroupsMap[groupId]?.renderType ??
        RenderType.kDefaultVertical);
  }

  private computeHasSecondarySide_(): boolean {
    const hasSecondarySide =
        !!this.groupIdsForSideType_(SideType.kSecondary).length;
    if (!this.hadSecondarySide) {
      this.hadSecondarySide = hasSecondarySide;
    }
    return hasSecondarySide;
  }

  private computeHasEmptyInput_(): boolean {
    return this.result && decodeString16(this.result.input) === '';
  }

  private computeHiddenGroupIds_(): number[] {
    return Object.keys(this.result?.suggestionGroupsMap ?? {})
        .map(groupId => Number.parseInt(groupId, 10))
        .filter(groupId => this.result.suggestionGroupsMap[groupId].hidden);
  }

  private isSelected_(match: AutocompleteMatch): boolean {
    return this.matchIndex_(match) === this.selectedMatchIndex;
  }

  /**
   * @returns The unique suggestion group IDs that belong to the given side type
   *     while preserving the order in which they appear in the list of matches.
   */
  private groupIdsForSideType_(side: SideType): number[] {
    return [...new Set<number>(
        this.result?.matches?.map(match => match.suggestionGroupId)
            .filter(groupId => this.sideTypeForGroup_(groupId) === side))];
  }

  /**
   * @returns Whether matches with the given suggestion group ID should be
   *     hidden.
   */
  private groupIsHidden_(groupId: number): boolean {
    return this.hiddenGroupIds_.indexOf(groupId) !== -1;
  }

  /**
   * @returns Whether the given suggestion group ID has a header.
   */
  private hasHeaderForGroup_(groupId: number): boolean {
    return !!this.headerForGroup_(groupId);
  }

  /**
   * @returns The header for the given suggestion group ID, if any.
   */
  private headerForGroup_(groupId: number): string {
    return this.result?.suggestionGroupsMap[groupId] ?
        decodeString16(this.result.suggestionGroupsMap[groupId].header) :
        '';
  }

  /**
   * @returns Index of the match in the autocomplete result. Passed to the match
   *     so it knows its position in the list of matches.
   */
  private matchIndex_(match: AutocompleteMatch): number {
    return this.result?.matches?.indexOf(match) ?? -1;
  }

  /**
   * @returns The list of visible matches that belong to the given suggestion
   *     group ID.
   */
  private matchesForGroup_(groupId: number): AutocompleteMatch[] {
    return this.groupIsHidden_(groupId) ?
        [] :
        (this.result?.matches ??
         []).filter(match => match.suggestionGroupId === groupId);
  }

  /**
   * @returns The list of side types to show.
   */
  private sideTypes_(): SideType[] {
    return this.showSecondarySide_ ?
        [SideType.kDefaultPrimary, SideType.kSecondary] :
        [SideType.kDefaultPrimary];
  }

  /**
   * @returns The side type for the given suggestion group ID.
   */
  private sideTypeForGroup_(groupId: number): SideType {
    return this.result?.suggestionGroupsMap[groupId]?.sideType ??
        SideType.kDefaultPrimary;
  }

  /**
   * @returns A11y label for suggestion group show/hide toggle button.
   */
  private toggleButtonA11yLabelForGroup_(groupId: number): string {
    if (!this.hasHeaderForGroup_(groupId)) {
      return '';
    }

    return !this.groupIsHidden_(groupId) ?
        decodeString16(
            this.result.suggestionGroupsMap[groupId].hideGroupA11yLabel) :
        decodeString16(
            this.result.suggestionGroupsMap[groupId].showGroupA11yLabel);
  }

  /**
   * @returns Icon name for suggestion group show/hide toggle button.
   */
  private toggleButtonIconForGroup_(groupId: number): string {
    return this.groupIsHidden_(groupId) ? 'icon-arrow-drop-down-cr23' :
                                          'icon-arrow-drop-up-cr23';
  }

  /**
   * @returns Tooltip for suggestion group show/hide toggle button.
   */
  private toggleButtonTitleForGroup_(groupId: number): string {
    return loadTimeData.getString(
        this.groupIsHidden_(groupId) ? 'showSuggestions' : 'hideSuggestions');
  }

  private computeShowSecondarySide_(): boolean {
    if (!this.canShowSecondarySide) {
      // Parent prohibits showing secondary side.
      return false;
    }

    if (!this.hiddenGroupIds_) {
      // Not ready yet as dropdown has received results but has not yet
      // determined which groups are hidden.
      return true;
    }

    // Only show secondary side if there are primary matches visible.
    const primaryGroupIds = this.groupIdsForSideType_(SideType.kDefaultPrimary);
    return primaryGroupIds.some((groupId) => {
      return this.matchesForGroup_(groupId).length > 0;
    });
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'cr-searchbox-dropdown': SearchboxDropdownElement;
  }
}

customElements.define(SearchboxDropdownElement.is, SearchboxDropdownElement);