chromium/chrome/browser/resources/history/history_item.ts

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

import './searched_label.js';
import './shared_style.css.js';
import './strings.m.js';
import 'chrome://resources/cr_elements/cr_checkbox/cr_checkbox.js';
import 'chrome://resources/cr_elements/cr_icons.css.js';
import 'chrome://resources/cr_elements/cr_shared_vars.css.js';
import 'chrome://resources/js/icon.js';
import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.js';

import {HistoryResultType} from 'chrome://resources/cr_components/history/constants.js';
import type {CrCheckboxElement} from 'chrome://resources/cr_elements/cr_checkbox/cr_checkbox.js';
import type {CrIconButtonElement} from 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.js';
import {FocusRowMixin} from 'chrome://resources/cr_elements/focus_row_mixin.js';
import {EventTracker} from 'chrome://resources/js/event_tracker.js';
import {focusWithoutInk} from 'chrome://resources/js/focus_without_ink.js';
import {getFaviconForPageURL} from 'chrome://resources/js/icon.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {afterNextRender, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {BrowserServiceImpl} from './browser_service.js';
import type {HistoryEntry} from './externs.js';
import {getTemplate} from './history_item.html.js';

export interface HistoryItemElement {
  $: {
    'checkbox': CrCheckboxElement,
    'icon': HTMLElement,
    'link': HTMLElement,
    'menu-button': CrIconButtonElement,
    'time-accessed': HTMLElement,
  };
}

const HistoryItemElementBase = FocusRowMixin(PolymerElement);

export class HistoryItemElement extends HistoryItemElementBase {
  static get is() {
    return 'history-item';
  }

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

  static get properties() {
    return {
      // Underlying HistoryEntry data for this.item. Contains read-only fields
      // from the history backend, as well as fields computed by history-list.
      item: {
        type: Object,
        observer: 'itemChanged_',
      },

      selected: {
        type: Boolean,
        reflectToAttribute: true,
      },

      isCardStart: {
        type: Boolean,
        reflectToAttribute: true,
      },

      isCardEnd: {
        type: Boolean,
        reflectToAttribute: true,
      },

      lastFocused: {
        type: Object,
        notify: true,
      },

      listBlurred: {
        type: Boolean,
        notify: true,
      },

      ironListTabIndex: {
        type: Number,
        observer: 'ironListTabIndexChanged_',
      },

      selectionNotAllowed_: Boolean,

      hasTimeGap: Boolean,

      index: Number,

      numberOfItems: Number,

      // Search term used to obtain this history-item.
      searchTerm: String,

      overrideCustomEquivalent: {
        type: Boolean,
        value: true,
      },

      ariaDescribedByForHeading_: {
        type: String,
        computed: 'getAriaDescribedByForHeading_(isCardStart, isCardEnd)',
      },

      ariaDescribedByForActions_: {
        type: String,
        computed: 'getAriaDescribedByForActions_(isCardStart, isCardEnd)',
      },
    };
  }

  private isShiftKeyDown_: boolean = false;
  private selectionNotAllowed_: boolean =
      !loadTimeData.getBoolean('allowDeletingHistory');
  private eventTracker_: EventTracker = new EventTracker();

  item: HistoryEntry;
  hasTimeGap: boolean;
  index: number;
  searchTerm: string;
  isCardStart: boolean;
  isCardEnd: boolean;
  numberOfItems: number;
  selected: boolean;

  override connectedCallback() {
    super.connectedCallback();

    afterNextRender(this, () => {
      // Adding listeners asynchronously to reduce blocking time, since these
      // history items are items in a potentially long list.
      this.eventTracker_.add(
          this.$.checkbox, 'keydown',
          (e: Event) => this.onCheckboxKeydown_(e as KeyboardEvent));
    });
  }

  override disconnectedCallback() {
    super.disconnectedCallback();
    this.eventTracker_.remove(this.$.checkbox, 'keydown');
  }

  private fire_(eventName: string, detail?: any) {
    this.dispatchEvent(
        new CustomEvent(eventName, {bubbles: true, composed: true, detail}));
  }

  focusOnMenuButton() {
    focusWithoutInk(this.$['menu-button']);
  }

  private onCheckboxKeydown_(e: KeyboardEvent) {
    if (e.shiftKey && e.key === 'Tab') {
      this.focus();
    }
  }

  /**
   * Toggle item selection whenever the checkbox or any non-interactive part
   * of the item is clicked.
   */
  private onRowClick_(e: MouseEvent) {
    const path = e.composedPath();
    // VoiceOver has issues with click events within elements that have a role
    // of row, so this event listeners has to be on the row itself.
    // (See crbug.com/1185827.)
    let inItemContainer = false;
    for (let i = 0; i < path.length; i++) {
      const elem = path[i] as HTMLElement;
      if (elem.id !== 'checkbox' &&
          (elem.nodeName === 'A' || elem.nodeName === 'CR-ICON-BUTTON')) {
        return;
      }

      if (!inItemContainer && elem.id === 'item-container') {
        inItemContainer = true;
      }
    }

    if (this.selectionNotAllowed_ || !inItemContainer) {
      return;
    }

    this.$.checkbox.focus();
    this.fire_('history-checkbox-select', {
      index: this.index,
      shiftKey: e.shiftKey,
    });
  }

  /**
   * This is bound to mouse/keydown instead of click/press because this
   * has to fire before onCheckboxChange_. If we bind it to click/press,
   * it might trigger out of desired order.
   */
  private onCheckboxClick_(e: MouseEvent) {
    this.isShiftKeyDown_ = e.shiftKey;
  }

  private onCheckboxChange_() {
    this.fire_('history-checkbox-select', {
      index: this.index,
      // If the user clicks or press enter/space key, oncheckboxClick_ will
      // trigger before this function, so a shift-key might be recorded.
      shiftKey: this.isShiftKeyDown_,
    });

    this.isShiftKeyDown_ = false;
  }

  private onRowMousedown_(e: MouseEvent) {
    // Prevent shift clicking a checkbox from selecting text.
    if (e.shiftKey) {
      e.preventDefault();
    }
  }

  private getEntrySummary_(): string {
    const item = this.item;
    return loadTimeData.getStringF(
        'entrySummary',
        this.isCardStart || this.isCardEnd ?
            this.cardTitle_(this.numberOfItems, this.searchTerm) :
            '',
        item.dateTimeOfDay,
        item.starred ? loadTimeData.getString('bookmarked') : '', item.title,
        item.domain);
  }

  /**
   * The first and last rows of a card have a described-by field pointing to
   * the date header, to make sure users know if they have jumped between cards
   * when navigating up or down with the keyboard.
   */
  private getAriaDescribedByForHeading_(): string {
    return this.isCardStart || this.isCardEnd ? 'date-accessed' : '';
  }

  /**
   * Actions menu is described by the title and domain of the row and may
   * include the date to make sure users know if they have jumped between dates.
   */
  private getAriaDescribedByForActions_(): string {
    return this.isCardStart || this.isCardEnd ?
        'title-and-domain date-accessed' :
        'title-and-domain';
  }

  private getAriaChecked_(selected: boolean): string {
    return selected ? 'true' : 'false';
  }

  /**
   * Remove bookmark of current item when bookmark-star is clicked.
   */
  private onRemoveBookmarkClick_() {
    if (!this.item.starred) {
      return;
    }

    if (this.shadowRoot!.querySelector('#bookmark-star') ===
        this.shadowRoot!.activeElement) {
      focusWithoutInk(this.$['menu-button']);
    }

    const browserService = BrowserServiceImpl.getInstance();
    browserService.removeBookmark(this.item.url);
    browserService.recordAction('BookmarkStarClicked');

    this.fire_('remove-bookmark-stars', this.item.url);
  }

  /**
   * Fires a custom event when the menu button is clicked. Sends the details
   * of the history item and where the menu should appear.
   */
  private onMenuButtonClick_(e: Event) {
    this.fire_('open-menu', {
      target: e.target,
      index: this.index,
      item: this.item,
    });

    // Stops the 'click' event from closing the menu when it opens.
    e.stopPropagation();
  }

  private onMenuButtonKeydown_(e: KeyboardEvent) {
    if (this.item.starred && e.shiftKey && e.key === 'Tab') {
      // If this item has a bookmark star, pressing shift + Tab from the more
      // actions menu should move focus to the star. FocusRow will try to
      // instead move focus to the previous focus row control, and since the
      // star is not a focus row control, stop immediate propagation here to
      // instead allow default browser behavior.
      e.stopImmediatePropagation();
    }
  }

  /**
   * Record metrics when a result is clicked.
   */
  private onLinkClick_() {
    const browserService = BrowserServiceImpl.getInstance();
    browserService.recordAction('EntryLinkClick');

    if (this.searchTerm) {
      browserService.recordAction('SearchResultClick');
    }

    this.fire_('record-history-link-click', {
      resultType: HistoryResultType.TRADITIONAL,
      index: this.index,
    });
  }

  private onLinkRightClick_() {
    BrowserServiceImpl.getInstance().recordAction('EntryLinkRightClick');
  }

  /**
   * Set the favicon image, based on the URL of the history item.
   */
  private itemChanged_() {
    this.$.icon.style.backgroundImage = getFaviconForPageURL(
        this.item.url, this.item.isUrlInRemoteUserData,
        this.item.remoteIconUrlForUma);
    this.eventTracker_.add(
        this.$['time-accessed'], 'mouseover', () => this.addTimeTitle_());
  }

  /**
   * @param numberOfItems The number of items in the card.
   * @param search The search term associated with these results.
   * @return The title for this history card.
   */
  private cardTitle_(numberOfItems: number, search: string): string {
    if (this.item === undefined) {
      return '';
    }

    if (!search) {
      return this.item.dateRelativeDay;
    }
    return searchResultsTitle(numberOfItems, search);
  }

  private addTimeTitle_() {
    const el = this.$['time-accessed'];
    el.setAttribute('title', new Date(this.item.time).toString());
    this.eventTracker_.remove(el, 'mouseover');
  }

  /**
   * @param sampleElement An element to find an equivalent for.
   * @return An equivalent element to focus, or null to use the
   *     default element.
   */
  override getCustomEquivalent(sampleElement: HTMLElement): HTMLElement|null {
    return sampleElement.getAttribute('focus-type') === 'star' ? this.$.link :
                                                                 null;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'history-item': HistoryItemElement;
  }
}

customElements.define(HistoryItemElement.is, HistoryItemElement);

/**
 * @return The title for a page of search results.
 */
export function searchResultsTitle(
    numberOfResults: number, searchTerm: string): string {
  const resultId = numberOfResults === 1 ? 'searchResult' : 'searchResults';
  return loadTimeData.getStringF(
      'foundSearchResults', numberOfResults, loadTimeData.getString(resultId),
      searchTerm);
}