chromium/chrome/browser/resources/ash/settings/os_settings_search_box/os_search_result_row.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.

/**
 * @fileoverview 'os-search-result-row' is the container for one search result.
 */
import 'chrome://resources/ash/common/cr_elements/icons.html.js';
// <if expr="_google_chrome">
import '/nearby/nearby-share-internal-icons.m.js';
// </if>
import '../os_settings_icons.html.js';
import '../settings_shared.css.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, assertNotReached} from 'chrome://resources/js/assert.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {mojoString16ToString} from 'chrome://resources/js/mojo_type_util.js';
import {OpenWindowProxyImpl} from 'chrome://resources/js/open_window_proxy.js';
import {sanitizeInnerHtml} from 'chrome://resources/js/parse_html_subset.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {isRevampWayfindingEnabled} from '../common/load_time_booleans.js';
import {SearchResult as PersonalizationSearchResult} from '../mojom-webui/personalization_search.mojom-webui.js';
import {Section, Subpage} from '../mojom-webui/routes.mojom-webui.js';
import {SearchResult as SettingsSearchResult, SearchResultIdentifier, SearchResultType} from '../mojom-webui/search.mojom-webui.js';
import {SearchResultIcon} from '../mojom-webui/search_result_icon.mojom-webui.js';
import {Setting} from '../mojom-webui/setting.mojom-webui.js';
import {Router} from '../router.js';
import {SearchResult} from '../search/combined_search_handler.js';

import {getTemplate} from './os_search_result_row.html.js';

/**
 * This solution uses DP and has the complexity of O(M*N), where M and N are
 * the lengths of |string1| and |string2| respectively.
 *
 * @param string1 The first case sensitive string to be compared.
 * @param string2 The second case sensitive string to be compared.
 * @return An array of the longest common substrings starting
 *     from the earliest to latest match, all of which have the same length.
 *     Returns empty array if there are none.
 */
function longestCommonSubstrings(string1: string, string2: string): string[] {
  let maxLength = 0;
  let string1StartingIndices: number[] = [];
  const dp = Array(string1.length + 1)
                 .fill([])
                 .map(() => Array(string2.length + 1).fill(0));

  for (let i = string1.length - 1; i >= 0; i--) {
    for (let j = string2.length - 1; j >= 0; j--) {
      if (string1[i] !== string2[j]) {
        continue;
      }
      dp[i][j] = dp[i + 1][j + 1] + 1;
      if (maxLength === dp[i][j]) {
        string1StartingIndices.unshift(i);
      }
      if (maxLength < dp[i][j]) {
        maxLength = dp[i][j];
        string1StartingIndices = [i];
      }
    }
  }

  return string1StartingIndices.map(idx => {
    return string1.substr(idx, maxLength);
  });
}

function isPersonalizationSearchResult(result: SearchResult):
    result is PersonalizationSearchResult {
  return !!result &&
      typeof (result as PersonalizationSearchResult).relativeUrl === 'string';
}

/**
 * Used to locate matches such that the query text omits a hyphen when the
 * matching result text contains a hyphen.
 */
const DELOCALIZED_HYPHEN = '-';

/**
 * A list of hyphens in all languages that will be ignored during the
 * tokenization and comparison of search result text.
 * Hyphen characters list is taken from here: http://jkorpela.fi/dashes.html.
 * U+002D(-), U+007E(~), U+058A(֊), U+05BE(־), U+1806(᠆), U+2010(‐),
 * U+2011(‑), U+2012(‒), U+2013(–), U+2014(—), U+2015(―), U+2053(⁓),
 * U+207B(⁻), U+208B(₋), U+2212(−), U+2E3A(⸺ ), U+2E3B(⸻  ), U+301C(〜),
 * U+3030(〰), U+30A0(゠), U+FE58(﹘), U+FE63(﹣), U+FF0D(-).
 */
const HYPHENS: string[] = [
  '-', '~', '֊', '־', '᠆', '‐',  '‑',  '‒',  '–',  '—',  '―',  '⁓',
  '⁻', '₋', '−', '⸺', '⸻', '〜', '〰', '゠', '﹘', '﹣', '-',
];

/**
 * String form of the regexp expressing hyphen chars.
 */
const HYPHENS_REGEX_STR = `[${HYPHENS.join('')}]`;

/**
 * Regexp expressing hyphen chars.
 */
const HYPHENS_REGEX = new RegExp(HYPHENS_REGEX_STR, 'g');

/**
 * @param sourceString The string to be modified.
 * @return The sourceString lowercased with accents in the range
 *     \u0300 - \u036f removed.
 */
function removeAccents(sourceString: string): string {
  return sourceString.toLocaleLowerCase().normalize('NFD').replace(
      /[\u0300-\u036f]/g, '');
}

/**
 * Used to convert the query and result into the same format without hyphens
 * and accents so that easy string comparisons can be performed. e.g.
 * |sourceString| = 'BRÛLÉE' returns "brulee"
 * @param sourceString The string to be normalized.
 * @return The sourceString lowercased with accents in the range
 *     \u0300 - \u036f removed, and with hyphens removed.
 */
function normalizeString(sourceString: string): string {
  return removeAccents(sourceString).replace(HYPHENS_REGEX, '');
}

/**
 * Bolds all strings in |substringsToBold| that occur in |sourceString|,
 * regardless of case.
 *     e.g. |sourceString| = "Turn on Wi-Fi"
 *          |substringsToBold| = ['o', 'wi-f', 'ur']
 *          returns 'T<b>ur</b>n <b>o</b>n <b>Wi-F</b>i'
 * @param sourceString The case sensitive string to be bolded.
 * @param substringsToBold The case-insensitive substrings
 *     that will be bolded in the |sourceString|, if they are html substrings
 *     of the |sourceString|.
 * @return An innerHTML string of |sourceString| with any
 *     |substringsToBold| regardless of case bolded.
 */
function boldSubStrings(
    sourceString: string, substringsToBold: string[]): string {
  if (!substringsToBold || !substringsToBold.length) {
    return sourceString;
  }
  const subStrRegex =
      new RegExp('(\)(' + substringsToBold.join('|') + ')(\)', 'ig');
  return sourceString.replace(subStrRegex, (match) => match.bold());
}

const OsSearchResultRowElementBase = FocusRowMixin(I18nMixin(PolymerElement));

export class OsSearchResultRowElement extends OsSearchResultRowElementBase {
  static get is() {
    return 'os-search-result-row';
  }

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

  static get properties() {
    return {
      /** 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,
      },

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

      searchResult: Object,

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

      resultText_: {
        type: String,
        computed: 'computeResultText_(searchResult)',
      },
    };
  }

  selected: boolean;
  override ariaLabel: string;
  searchQuery: string;
  searchResult: SearchResult;
  listLength: number;
  private resultText_: string;

  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);
  }

  private computeResultText_(): string {
    // The C++ layer stores the text result as an array of 16 bit char codes,
    // so it must be converted to a JS String.
    return mojoString16ToString(this.searchResult.text);
  }

  /**
   * Bolds individual characters in the result text that are characters in the
   * search query, regardless of order. Some languages represent words with
   * single characters and do not include spaces. In those instances, use
   * exact character matching.
   *     e.g |this.resultText_| = "一二三四"
   *         |this.searchQuery| = "三一"
   *         returns "<b>一</b>二<b>三</b>四"
   * @return An innerHTML string of |this.resultText_| with any
   *     character that is in |this.searchQuery| bolded.
   */
  private getMatchingIndividualCharsBolded_(): string {
    return boldSubStrings(
        /*sourceString=*/ this.resultText_,
        /*substringsToBold=*/ this.searchQuery.split(''));
  }

  /**
   * @param innerHtmlToken A case sensitive segment of the result
   *     text which may or may not contain hyphens or accents on
   *     characters, and does not contain blank spaces.
   * @param normalizedQuery A lowercased query which does not contain
   *     hyphens.
   * @param queryTokens See generateQueryTokens_().
   * @return The innerHtmlToken with <b> tags around segments that
   *     match queryTokens, but also includes hyphens and accents
   *     on characters.
   */
  private getModifiedInnerHtmlToken_(
      innerHtmlToken: string, normalizedQuery: string,
      queryTokens: string[]): string {
    // For comparison purposes with query tokens, lowercase the html token to
    // be displayed, remove hyphens, and remove accents. The resulting
    // |normalizedToken| will not be the displayed token.
    const normalizedToken = normalizeString(innerHtmlToken);
    if (normalizedQuery.includes(normalizedToken)) {
      // Bold the entire html token to be displayed, if the result is a
      // substring of the query, regardless of blank spaces that may or
      // may not have not been extraneous.
      return normalizedToken ? innerHtmlToken.bold() : innerHtmlToken;
    }

    // Filters out query tokens that are not substrings of the currently
    // processing text token to be displayed.
    const queryTokenFilter = (queryToken: string): boolean => {
      return !!queryToken && normalizedToken.includes(queryToken);
    };

    // Maps the queryToken to the segment(s) of the html token that contain
    // the queryToken interweaved with any of the hyphens that were
    // filtered out during normalization. For example, |innerHtmlToken| =
    // 'Wi-Fi-no-blankspsc-WiFi', (i.e. |normalizedToken| =
    // 'WiFinoblankspcWiFi') and |queryTokenLowerCaseNoSpecial| = 'wif', the
    // resulting mapping would be ['Wi-F', 'WiF'].
    const queryTokenToSegment = (queryToken: string): string[] => {
      const regExpStr = queryToken.split('').join(`${HYPHENS_REGEX_STR}*`);

      // Since |queryToken| does not contain accents and |innerHtmlToken| may
      // have accents matches must be made without accents on characters.
      const innerHtmlTokenNoAccents = removeAccents(innerHtmlToken);
      const matchesNoAccents: string[] =
          innerHtmlTokenNoAccents.match(new RegExp(regExpStr, 'g')) || [];

      // Return matches with original accents restored.
      return matchesNoAccents.map(
          match => innerHtmlToken.toLocaleLowerCase().substr(
              innerHtmlTokenNoAccents.indexOf(match), match.length));
    };

    // Contains lowercase segments of the innerHtmlToken that may or may not
    // contain hyphens and accents on characters.
    const matches =
        queryTokens.filter(queryTokenFilter).map(queryTokenToSegment).flat();

    if (!matches.length) {
      // No matches, return token to displayed as is.
      return innerHtmlToken;
    }

    // Get the length of the longest matched substring(s).
    const maxStrLen =
        matches.reduce((a, b) => a.length > b.length ? a : b).length;

    // Bold the longest substring(s).
    const bolded =
        matches.filter(sourceString => sourceString.length === maxStrLen);
    return boldSubStrings(
        /*sourceString=*/ innerHtmlToken, /*substringsToBold=*/ bolded);
  }

  /**
   * Query tokens are created first by splitting the |normalizedQuery| with
   * blankspaces into query segments. Then, each query segment is compared
   * to the the normalized result text (result text without hyphens or
   * accents). Query tokens are created by finding the longest common
   * substring(s) between a query segment and the normalized result text. Each
   * query segment is mapped to an array of their query tokens. Finally, the
   * longest query token(s) for each query segment are extracted. In the event
   * that query segments are more than one character long, query tokens that
   * are only one character long are ignored.
   * @param normalizedQuery A lowercased query which does not contain
   *     hyphens or accents.
   * @return QueryTokens that do not contain
   *     blankspaces and are substrings of the normalized result text
   */
  private generateQueryTokens_(normalizedQuery: string): string[] {
    const normalizedResultText = normalizeString(this.resultText_);

    const segmentToTokenMap = new Map<string, string[]>();
    normalizedQuery.split(/\s/).forEach(querySegment => {
      const queryTokens =
          longestCommonSubstrings(querySegment, normalizedResultText);
      if (segmentToTokenMap.has(querySegment)) {
        const segmentTokens =
            segmentToTokenMap.get(querySegment)!.concat(queryTokens);
        segmentToTokenMap.set(querySegment, segmentTokens);
        return;
      }
      segmentToTokenMap.set(querySegment, queryTokens);
    });

    // For each segment, only return the longest token. For example, in the
    // case that |resultText_| is "Search and Assistant", a |querySegment| key
    // of "ssistan" will yield a |queryToken| value array containing "ssistan"
    // (longest common substring for "Assistant") and "an" (longest common
    // substring for "and"). Only the queryToken "ssistan" should be kept
    // since it's the longest queryToken.
    const getLongestTokensPerSegment =
        ([querySegment, queryTokens]: [string, string[]]): string[] => {
          // If there are no queryTokens, return none.
          // Example: |normalizedResultText| = "search and assistant"
          //          |normalizedQuery| = "hi goog"
          //          |querySegment| = "goog"
          //          |queryTokens| = []
          // Since |querySegment| does not share any substrings with
          // |normalizedResultText|, no queryTokens available.
          if (!queryTokens.length) {
            return [];
          }

          const maxLengthQueryToken =
              Math.max(...queryTokens.map(queryToken => queryToken.length));

          // If the |querySegment| is more than one character long and the
          // longest queryToken(s) are one character long, discard all
          // queryToken(s). This prevents random single characters in in the
          // result text from bolding. Example: |normalizedResultText| = "search
          // and assistant"
          //          |normalizedQuery| = "hi goog"
          //          |querySegment| = "hi"
          //          |queryTokens| = ["h", "i"]
          // Here, |querySegment| "hi" shares a common substring "h" with
          // |normalizedResultText|'s "search" and "i" with
          // |normalizedResultText|'s "assistant". Since the queryTokens for
          // the length two querySegment are only one character long, discard
          // the queryTokens.
          if (maxLengthQueryToken === 1 && querySegment.length > 1) {
            return [];
          }

          return queryTokens.filter(
              queryToken => queryToken.length === maxLengthQueryToken);
        };

    // A 2D array such that each array contains queryTokens of a querySegment.
    // Note that the order of key value pairs is maintained in the
    // |segmentToTokenMap| relative to the |normalizedQuery|, and the order
    // of the queryTokens within each inner array is also maintained relative
    // to the |normalizedQuery|.
    const inOrderTokenGroups =
        Array.from(segmentToTokenMap).map(getLongestTokensPerSegment);

    // Flatten the 2D |inOrderTokenGroups|, and remove duplicate queryTokens.
    // Note that even though joining |inOrderTokens| will always form a
    // subsequence of |normalizedQuery|, it will not be a subsequence of
    // |normalizedResultText|.
    // Example: |this.resultText| = "Touchpad tap-to-click"
    //          |normalizedResultText| = "touchpad taptoclick"
    //          |normalizedQuery| = "tap to cli"
    //          |inOrderTokenGroups| = [['tap']. ['to', 'to']. ['cli']]
    //          |inOrderTokens| = ['tap', 'to', 'cli']
    // |inOrderTokenGroups| contains an inner array of two 'to's because
    // the |querySegment| = 'to' matches with 'touchpad' and 'taptoclick'.
    // Duplicate entries are removed in |inOrderTokens| because
    // if a |queryToken| is merged to form a compound worded queryToken, it
    // should not be used to bold another |resultText| word. In the fictitious
    // case that |inOrderTokenGroups| is [['tap']. ['to', 'xy']. ['cli']],
    // |inOrderTokens| will be ['tap', 'to', 'xy', 'cli'], and only 'Tap-to'
    // will be bolded. This is fine because 'toxy' is a subsequence of a
    // |querySegment| the user inputted, and the order of bolding
    // will prefer the user's input in these extenuating circumstances.
    const inOrderTokens = [...new Set(inOrderTokenGroups.flat())];
    return this.mergeValidTokensToCompounded_(inOrderTokens);
  }

  /**
   * Possibly merges costituent queryTokens in |inOrderQueryTokens| to form
   * new, longer, valid queryTokens that match with normalized compounded
   * words in |this.resultText|.
   * @param inOrderQueryTokens An array of valid queryTokens
   *     that do not contain dups.
   * @return An array of queryTokens of equal or lesser size
   *     than |inOrderQueryTokens|, each of which do not contain blankspaces
   *     and are substrings of the normalized result text.
   */
  private mergeValidTokensToCompounded_(inOrderQueryTokens: string[]):
      string[] {
    // If |this.resultToken| does not contain any hyphens, this will be
    // be the same as |inOrderQueryTokens|.
    const longestCompoundWordTokens: string[] = [];

    // Instead of stripping all hyphen as would be the case if the result
    // text were normalized, convert all hyphens to |DELOCALIZED_HYPHEN|. This
    // string will be compared with compound query tokens to find query tokens
    // that are compound substrings longer than the constituent query tokens.
    const hyphenatedResultText =
        removeAccents(this.resultText_)
            .replace(HYPHENS_REGEX, DELOCALIZED_HYPHEN);

    // Create the longest combined tokens delimited by |DELOCALIZED_HYPHEN|s
    // that are a substrings of |hyphenatedResultText|. Worst case visit each
    // token twice. Note that if a token is used to form a compound word, it
    // will no longer be present for other words.
    // Example: |this.resultText| = "Touchpad tap-to-click"
    //          |this.searchQuery| = "tap to clic"
    // The token "to" will fail to highlight "To" in "Touchpad", and instead
    // will be combined with "tap" and "clic" to bold "tap-to-click".
    let i = 0;
    while (i < inOrderQueryTokens.length) {
      let prefixToken = inOrderQueryTokens[i];
      i++;
      while (i < inOrderQueryTokens.length) {
        // Create a compound token with the next token within
        // |inOrderQueryTokens|.
        const compoundToken =
            prefixToken + DELOCALIZED_HYPHEN + inOrderQueryTokens[i];

        // If the constructed compoundToken from valid queryTokens is not a
        // substring of the |hyphenatedResultText|, break from the inner loop
        // and set the outer loop to start with the token that broke the
        // compounded match.
        if (!hyphenatedResultText.includes(compoundToken)) {
          break;
        }

        prefixToken = compoundToken;
        i++;
      }
      longestCompoundWordTokens.push(prefixToken);
    }

    // Normalize the compound tokens that include |DELOCALIZED_HYPHEN|s.
    return longestCompoundWordTokens.map(token => normalizeString(token));
  }

  /**
   * Tokenize the result and query text, and match the tokens even if they
   * are out of order. Both the result and query tokens are compared without
   * hyphens or accents on characters. Result text is simply tokenized by
   * blankspaces. On the other hand, query text is tokenized within
   * generateQueryTokens_(). As each result token is processed, it is compared
   * with every query token. Bold the segment of the result token that is a
   * query token. e.g. Smaller query block: if "wif on" is
   * queried, a result text of "Turn on Wi-Fi" should have "on" and "Wi-F"
   * bolded. e.g. Larger query block: If "onwifi" is queried, a result text of
   * "Turn on Wi-Fi" should have "Wi-Fi" bolded.
   * @return Result string with <b> tags around query sub string.
   */
  private getTokenizeMatchedBoldTagged_(): string {
    // Lowercase, remove hyphens, and remove accents from the query.
    const normalizedQuery = normalizeString(this.searchQuery);

    const queryTokens = this.generateQueryTokens_(normalizedQuery);

    // Get innerHtmlTokens with bold tags around matching segments.
    const innerHtmlTokensWithBoldTags = this.resultText_.split(/\s/).map(
        innerHtmlToken => this.getModifiedInnerHtmlToken_(
            innerHtmlToken, normalizedQuery, queryTokens));

    // Get all blankspace types.
    const blankspaces = this.resultText_.match(/\s/g);

    if (!blankspaces) {
      // No blankspaces, return |innterHtmlTokensWithBoldTags| as a string.
      return innerHtmlTokensWithBoldTags.join('');
    }

    // Add blankspaces make to where they were located in the string, and
    // form one string to be added to the html.
    // e.g |blankspaces| = [' ', '\xa0']
    //     |innerHtmlTokensWithBoldTags| = ['a', '<b>b</b>', 'c']
    // returns 'a <b>b</b>&nbps;c'
    return innerHtmlTokensWithBoldTags
        .map((token, idx) => {
          return idx !== blankspaces.length ? token + blankspaces[idx] : token;
        })
        .join('');
  }

  /**
   * @return The result string with <span> tags around keywords.
   */
  private getResultInnerHtml_(): TrustedHTML {
    if (!(this.searchResult as SettingsSearchResult)
             .wasGeneratedFromTextMatch) {
      return sanitizeInnerHtml(this.resultText_);
    }

    if (this.resultText_.match(/\s/) ||
        this.resultText_.toLocaleLowerCase() !==
            this.resultText_.toLocaleUpperCase()) {
      // If the result text includes blankspaces (as they commonly will in
      // languages like Arabic and Hindi), or if the result text includes
      // at least one character such that the lowercase is different from
      // the uppercase (as they commonly will in languages like English
      // and Russian), tokenize the result text by blankspaces, and bold based
      // off of matching substrings in the tokens.
      return sanitizeInnerHtml(this.getTokenizeMatchedBoldTagged_());
    }

    // If the result text does not contain blankspaces or characters that
    // have upper/lower case differentiation (as they commonly do in languages
    // like Chinese and Japanese), bold exact characters that match.
    return sanitizeInnerHtml(this.getMatchingIndividualCharsBolded_());
  }

  /**
   * @return Aria label string for ChromeVox to verbalize.
   */
  private computeAriaLabel_(): string {
    assert(typeof this.focusRowIndex === 'number');
    return this.i18n(
        'searchResultSelected', this.focusRowIndex + 1, this.listLength,
        this.computeResultText_());
  }

  /**
   * 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();
    }
  }

  private recordSearchResultMetrics_(): void {
    if (isPersonalizationSearchResult(this.searchResult)) {
      chrome.metricsPrivate.recordSparseValue(
          'ChromeOS.Settings.SearchResultPersonalizationSelected',
          this.searchResult.searchConceptId);
      // Record entry point metric to Personalization Hub through Settings
      // search.
      chrome.metricsPrivate.recordEnumerationValue(
          'Ash.Personalization.EntryPoint',
          loadTimeData.getInteger('settingsSearchEntryPoint'),
          loadTimeData.getInteger('entryPointEnumSize'));
      return;
    }

    const settingsSearchResult = this.searchResult as SettingsSearchResult;
    chrome.metricsPrivate.recordEnumerationValue(
        'ChromeOS.Settings.SearchResultTypeSelected', settingsSearchResult.type,
        SearchResultType.MAX_VALUE);

    interface MetricArg {
      metricName: string;
      value?: Section|Subpage|Setting;
    }
    const metricArgs =
        (type: number, id: SearchResultIdentifier): MetricArg => {
          switch (type) {
            case SearchResultType.kSection:
              return {
                metricName: 'ChromeOS.Settings.SearchResultSectionSelected',
                value: id.section,
              };
            case SearchResultType.kSubpage:
              return {
                metricName: 'ChromeOS.Settings.SearchResultSubpageSelected',
                value: id.subpage,
              };
            case SearchResultType.kSetting:
              return {
                metricName: 'ChromeOS.Settings.SearchResultSettingSelected',
                value: id.setting,
              };
            default:
              assertNotReached('Search Result Type not specified.');
          }
        };

    const args =
        metricArgs(settingsSearchResult.type, settingsSearchResult.id)!;
    if (args.value) {
      chrome.metricsPrivate.recordSparseValue(args.metricName, args.value);
    }
  }

  /**
   * Navigate to a search result route or launch an external url based on
   * the search result's id.
   */
  onSearchResultSelected(): void {
    if (isPersonalizationSearchResult(this.searchResult)) {
      this.recordSearchResultMetrics_();
      OpenWindowProxyImpl.getInstance().openUrl(
          loadTimeData.getString('personalizationAppUrl') +
          this.searchResult.relativeUrl);
      return;
    }

    const settingsSearchResult = this.searchResult as SettingsSearchResult;
    assert(settingsSearchResult.urlPathWithParameters, 'Url path is empty.');
    this.recordSearchResultMetrics_();

    // |this.searchResult.urlPathWithParameters| separates the path and params
    // by a '?' char.
    const pathAndOptParams =
        settingsSearchResult.urlPathWithParameters.split('?');

    // There should be at most 2 items in the array (the path and the params).
    assert(pathAndOptParams.length <= 2, 'Path and params format error.');

    const route =
        Router.getInstance().getRouteForPath('/' + pathAndOptParams[0]);
    assert(
        route,
        'Supplied path does not map to an existing route: ' +
            pathAndOptParams[0]);

    const paramsString = `search=${encodeURIComponent(this.searchQuery)}` +
        (pathAndOptParams.length === 2 ? `&${pathAndOptParams[1]}` : ``);
    const params = new URLSearchParams(paramsString);
    Router.getInstance().navigateTo(route, params);

    const event = new CustomEvent(
        'navigated-to-result-route', {bubbles: true, composed: true});
    this.dispatchEvent(event);
  }

  /**
   * @return The name of the icon to use.
   */
  private getResultIcon_(): string {
    const isRevampEnabled = isRevampWayfindingEnabled();
    if (isPersonalizationSearchResult(this.searchResult)) {
      return isRevampEnabled ? 'os-settings:personalization-revamp' :
                               'os-settings:paint-brush';
    }

    const settingsSearchResult = this.searchResult as SettingsSearchResult;
    switch (settingsSearchResult.icon) {
      case SearchResultIcon.kA11y:
        return isRevampEnabled ? 'os-settings:accessibility-revamp' :
                                 'os-settings:accessibility';
      case SearchResultIcon.kAndroid:
        return 'os-settings:android';
      case SearchResultIcon.kAppsParentalControls:
        return 'os-settings:apps-parental-controls';
      case SearchResultIcon.kAppsGrid:
        return 'os-settings:apps';
      case SearchResultIcon.kAssistant:
        return 'os-settings:assistant';
      case SearchResultIcon.kAudio:
        return isRevampEnabled ? 'os-settings:device-audio' :
                                 'os-settings:audio';
      case SearchResultIcon.kAuthKey:
        return 'os-settings:auth-key';
      case SearchResultIcon.kAutoclick:
        return 'os-settings:autoclick';
      case SearchResultIcon.kSwitchAccess:
        return 'os-settings:switch-access';
      case SearchResultIcon.kAvatar:
        return isRevampEnabled ? 'os-settings:privacy-manage-people' :
                                 'cr:person';
      case SearchResultIcon.kBluetooth:
        return 'cr:bluetooth';
      case SearchResultIcon.kCamera:
        return 'os-settings:camera';
      case SearchResultIcon.kCellular:
        return 'os-settings:cellular';
      case SearchResultIcon.kCheckForUpdate:
        return 'os-settings:about-update-complete';
      case SearchResultIcon.kChrome:
        return 'os-settings:chrome';
      case SearchResultIcon.kClock:
        return 'os-settings:clock';
      case SearchResultIcon.kContrast:
        return 'os-settings:contrast';
      case SearchResultIcon.kCursorClick:
        return 'os-settings:cursor-click';
      case SearchResultIcon.kDetailedBuild:
        return 'os-settings:about-additional-details';
      case SearchResultIcon.kDeveloperTags:
        return 'os-settings:developer-tags';
      case SearchResultIcon.kDiagnostics:
        return 'os-settings:about-diagnostics';
      case SearchResultIcon.kDictation:
        return 'os-settings:dictation';
      case SearchResultIcon.kDisplay:
        return isRevampEnabled ? 'os-settings:device-display' :
                                 'os-settings:display';
      case SearchResultIcon.kDockedMagnifier:
        return 'os-settings:docked-magnifier';
      case SearchResultIcon.kEthernet:
        return 'os-settings:settings-ethernet';
      case SearchResultIcon.kFingerprint:
        return 'os-settings:fingerprint';
      case SearchResultIcon.kFirmwareUpdates:
        return 'os-settings:about-firmware-updates';
      case SearchResultIcon.kFolder:
        return 'os-settings:folder-outline';
      case SearchResultIcon.kFolderShared:
        return 'os-settings:folder-shared';
      case SearchResultIcon.kFullscreenMagnifier:
        return 'os-settings:fullscreen-magnifier';
      case SearchResultIcon.kGeolocation:
        return 'os-settings:geolocation';
      case SearchResultIcon.kGoogleDrive:
        return isRevampEnabled ? 'os-settings:google-drive-revamp' :
                                 'os-settings:google-drive';
      case SearchResultIcon.kGooglePlay:
        return isRevampEnabled ? 'os-settings:google-play-revamp' :
                                 'os-settings:google-play';
      case SearchResultIcon.kHearing:
        return 'os-settings:a11y-hearing';
      case SearchResultIcon.kHelp:
        return 'os-settings:about-help';
      case SearchResultIcon.kHelpMeRead:
        return 'os-settings:help-me-read';
      case SearchResultIcon.kHelpMeWrite:
        return 'os-settings:help-me-write';
      case SearchResultIcon.kHotspot:
        return 'os-settings:hotspot';
      case SearchResultIcon.kInstantTethering:
        return 'os-settings:magic-tethering';
      case SearchResultIcon.kKeyboard:
        return isRevampEnabled ? 'os-settings:device-keyboard' :
                                 'os-settings:keyboard';
      case SearchResultIcon.kLanguage:
        return isRevampEnabled ? 'os-settings:language-revamp' :
                                 'os-settings:language';
      case SearchResultIcon.kLaptop:
        return 'os-settings:laptop-chromebook';
      case SearchResultIcon.kLock:
        return isRevampEnabled ? 'os-settings:lock-revamp' : 'os-settings:lock';
      case SearchResultIcon.kMagicBoost:
        return 'os-settings:magic-boost';
      case SearchResultIcon.kMicrophone:
        return 'os-settings:microphone';
      case SearchResultIcon.kMouse:
        return isRevampEnabled ? 'os-settings:device-mouse' :
                                 'os-settings:mouse';
      case SearchResultIcon.kNearbyShare:
        // <if expr="_google_chrome">
        if (loadTimeData.getBoolean('isNameEnabled')) {
          return 'nearby-share-internal:nearby-share';
        }
        // </if>
        return 'os-settings:nearby-share';
      case SearchResultIcon.kNotifications:
        return 'os-settings:apps-notifications';
      case SearchResultIcon.kOneDrive:
        return 'settings20:onedrive';
      case SearchResultIcon.kOnScreenKeyboard:
        return 'os-settings:on-screen-keyboard';
      case SearchResultIcon.kPaintbrush:
        return isRevampEnabled ? 'os-settings:personalization-revamp' :
                                 'os-settings:paint-brush';
      case SearchResultIcon.kPenguin:
        return 'os-settings:crostini-mascot';
      case SearchResultIcon.kPersonalization:
        return 'os-settings:personalization';
      case SearchResultIcon.kPhone:
        return isRevampEnabled ?
            'os-settings:connected-devices-android-phone' :
            'os-settings:multidevice-better-together-suite';
      case SearchResultIcon.kPluginVm:
        return 'os-settings:plugin-vm';
      case SearchResultIcon.kPointingStick:
        return 'os-settings:device-pointing-stick';
      case SearchResultIcon.kPower:
        return 'os-settings:power';
      case SearchResultIcon.kPrinter:
        return isRevampEnabled ? 'os-settings:device-print' :
                                 'os-settings:print';
      case SearchResultIcon.kPrivacyControls:
        return 'os-settings:privacy-controls';
      case SearchResultIcon.kReducedAnimations:
        return 'os-settings:reduced-animations';
      case SearchResultIcon.kReleaseNotes:
        return 'os-settings:about-release-notes';
      case SearchResultIcon.kReset:
        return isRevampEnabled ? 'os-settings:startup' : 'os-settings:restore';
      case SearchResultIcon.kRestore:
        return isRevampEnabled ? 'os-settings:restore-revamp' :
                                 'os-settings:startup';
      case SearchResultIcon.kScanner:
        return 'os-settings:device-scan';
      case SearchResultIcon.kSearch:
        return isRevampEnabled ? 'os-settings:explore' : 'cr:search';
      case SearchResultIcon.kSelectToSpeak:
        return 'os-settings:select-to-speak';
      case SearchResultIcon.kShield:
        return 'cr:security';
      case SearchResultIcon.kSnapWindowSuggestions:
        return 'os-settings:snap-window-suggestions';
      case SearchResultIcon.kStorage:
        return isRevampEnabled ? 'os-settings:storage' :
                                 'os-settings:hard-drive';
      case SearchResultIcon.kStylus:
        return isRevampEnabled ? 'os-settings:device-stylus' :
                                 'os-settings:stylus';
      case SearchResultIcon.kSync:
        return isRevampEnabled ? 'os-settings:sync-revamp' : 'os-settings:sync';
      case SearchResultIcon.kSystemPreferences:
        return 'os-settings:system-preferences';
      case SearchResultIcon.kTextToSpeech:
        return 'os-settings:text-to-speech';
      case SearchResultIcon.kTouchpad:
        return 'os-settings:device-touchpad';
      case SearchResultIcon.kWallpaper:
        return 'os-settings:wallpaper';
      case SearchResultIcon.kWifi:
        return 'os-settings:network-wifi';
      case SearchResultIcon.kZoomIn:
        return 'os-settings:zoom-in';
      default:
        return 'os-settings:settings-general';
    }
  }

  private getActionTypeIcon_(): string {
    return isPersonalizationSearchResult(this.searchResult) ?
        'cr:open-in-new' :
        'cr:arrow-forward';
  }
}

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

customElements.define(OsSearchResultRowElement.is, OsSearchResultRowElement);