chromium/ash/webui/shortcut_customization_ui/resources/js/search/search_result_row.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_shared_style.css.js';
import '../text_accelerator.js';
import 'chrome://resources/ash/common/shortcut_input_ui/shortcut_input_key.js';

import {getInstance as getAnnouncerInstance} from 'chrome://resources/ash/common/cr_elements/cr_a11y_announcer/cr_a11y_announcer.js';
import {FocusRowMixin} from 'chrome://resources/ash/common/cr_elements/focus_row_mixin.js';
import {I18nMixin} from 'chrome://resources/ash/common/cr_elements/i18n_mixin.js';
import {assert} from 'chrome://resources/js/assert.js';
import {mojoString16ToString} from 'chrome://resources/js/mojo_type_util.js';
import {PolymerElementProperties} from 'chrome://resources/polymer/v3_0/polymer/interfaces.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {AcceleratorLookupManager} from '../accelerator_lookup_manager.js';
import {Router} from '../router.js';
import {LayoutStyle, MetaKey, MojoAcceleratorInfo, MojoSearchResult, StandardAcceleratorInfo, TextAcceleratorInfo, TextAcceleratorPart} from '../shortcut_types.js';
import {getAriaLabelForStandardAccelerators, getAriaLabelForTextAccelerators, getModifiersForAcceleratorInfo, getTextAcceleratorParts, getURLForSearchResult, isStandardAcceleratorInfo, isTextAcceleratorInfo} from '../shortcut_utils.js';

import {getBoldedDescription} from './search_result_bolding.js';
import {getTemplate} from './search_result_row.html.js';

/**
 * @fileoverview
 * 'search-result-row' is the container for one search result.
 */

const SearchResultRowElementBase = FocusRowMixin(I18nMixin(PolymerElement));

export class SearchResultRowElement extends SearchResultRowElementBase {
  static get is(): string {
    return 'search-result-row';
  }

  static get properties(): PolymerElementProperties {
    return {
      searchResult: {
        type: Object,
      },

      /** The query used to fetch this result. */
      searchQuery: {
        type: String,
      },

      /** Whether the search result row is selected. */
      selected: {
        type: Boolean,
        reflectToAttribute: true,
        observer: 'makeA11yAnnouncementIfSelectedAndUnfocused',
      },

      /** Aria label for the row. */
      ariaLabel: {
        type: String,
        computed: 'computeAriaLabel(searchResult)',
        reflectToAttribute: true,
      },

      /** Number of rows in the list this row is part of. */
      listLength: Number,

      /** The meta key on the keyboard to display to the user. */
      metaKey: Object,
    };
  }

  override ariaLabel: string;
  listLength: number;
  searchResult: MojoSearchResult;
  searchQuery: string;
  selected: boolean;
  metaKey: MetaKey = MetaKey.kSearch;
  private lookupManager: AcceleratorLookupManager =
      AcceleratorLookupManager.getInstance();

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

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

    this.metaKey = this.lookupManager.getMetaKeyToDisplay();
  }

  private isNoShortcutAssigned(): boolean {
    return this.searchResult.acceleratorInfos.length === 0;
  }

  private isStandardLayout(): boolean {
    return !this.isNoShortcutAssigned() &&
        this.searchResult.acceleratorLayoutInfo.style === LayoutStyle.kDefault;
  }

  private isTextLayout(): boolean {
    return !this.isNoShortcutAssigned() && !this.isStandardLayout();
  }

  private getTextAcceleratorParts(): TextAcceleratorPart[] {
    assert(isTextAcceleratorInfo(this.searchResult.acceleratorInfos[0]));
    return getTextAcceleratorParts(
        this.searchResult.acceleratorInfos as TextAcceleratorInfo[]);
  }

  private getStandardAcceleratorInfos(): StandardAcceleratorInfo[] {
    assert(this.isStandardLayout());
    // Convert MojoAcceleratorInfo from the search result into the converted
    // AcceleratorInfo type used throughout the app.
    // In practice, this means that we need to convert the String16 keyDisplay
    // property on each accelerator into strings.
    return this.searchResult.acceleratorInfos.map(
        (acceleratorInfo: MojoAcceleratorInfo) => {
          assert(isStandardAcceleratorInfo(acceleratorInfo));
          return {
            ...acceleratorInfo,
            layoutProperties: {
              ...acceleratorInfo.layoutProperties,
              standardAccelerator: {
                ...acceleratorInfo.layoutProperties.standardAccelerator,
                keyDisplay:
                    mojoString16ToString(acceleratorInfo.layoutProperties
                                             .standardAccelerator.keyDisplay),
              },
            },
            // Cast to StandardAcceleratorInfo here since that type uses strings
            // instead of String16s, and we couldn't perform the assignment
            // above if it were still a MojoAcceleratorInfo.
          } as StandardAcceleratorInfo;
        });
  }

  private getStandardAcceleratorModifiers(
      acceleratorInfo: StandardAcceleratorInfo): string[] {
    return getModifiersForAcceleratorInfo(acceleratorInfo);
  }

  private getStandardAcceleratorKey(acceleratorInfo: StandardAcceleratorInfo):
      string {
    return acceleratorInfo.layoutProperties.standardAccelerator.keyDisplay;
  }

  // If true, show "or" after the AcceleratorInfo at the given index.
  private shouldShowTextDivider(indexOfAcceleratorInfo: number): boolean {
    return indexOfAcceleratorInfo !==
        this.searchResult.acceleratorInfos.length - 1;
  }

  /**
   * Only relevant when the focus-row-control is focus()ed. This keypress
   * handler specifies that pressing 'Enter' should cause a route change.
   */
  private onKeyPress(e: KeyboardEvent): void {
    if (e.key === 'Enter') {
      e.stopPropagation();
      this.onSearchResultSelected();
    }
  }

  /**
   * Navigate to a search result route based on the search result.
   */
  onSearchResultSelected(): void {
    Router.getInstance().navigateTo(getURLForSearchResult(this.searchResult));
    this.dispatchEvent(new CustomEvent(
        'navigated-to-result-route', {bubbles: true, composed: true}));
  }

  private getSearchResultDescriptionInnerHtml(): TrustedHTML {
    return getBoldedDescription(
        mojoString16ToString(
            this.searchResult.acceleratorLayoutInfo.description),
        this.searchQuery);
  }

  /**
   * @return Aria label string for ChromeVox to verbalize.
   */
  private computeAriaLabel(): string {
    const description = mojoString16ToString(
        this.searchResult.acceleratorLayoutInfo.description);
    let searchResultText;

    if (this.isNoShortcutAssigned()) {
      searchResultText = `${description}, ${this.i18n('noShortcutAssigned')}`;
    } else if (this.isStandardLayout()) {
      searchResultText = `${description}, ${
          getAriaLabelForStandardAccelerators(
              this.getStandardAcceleratorInfos(),
              this.i18n('acceleratorTextDivider'))}`;
    } else {
      searchResultText = `${description}, ${
          getAriaLabelForTextAccelerators(
              this.searchResult.acceleratorInfos as TextAcceleratorInfo[])}`;
    }

    return this.i18n(
        'searchResultSelectedAriaLabel', this.focusRowIndex + 1,
        this.listLength, searchResultText);
  }

  private makeA11yAnnouncementIfSelectedAndUnfocused(): void {
    if (!this.selected || this.lastFocused) {
      // Do not alert the user if the result is not selected, or
      // the list is focused, defer to aria tags instead.
      return;
    }

    // The selected item is normally not focused when selected, the
    // selected search result should be verbalized as it changes.
    getAnnouncerInstance().announce(this.ariaLabel);
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'search-result-row': SearchResultRowElement;
  }
}

customElements.define(SearchResultRowElement.is, SearchResultRowElement);