chromium/chrome/browser/resources/side_panel/bookmarks/power_bookmark_row.ts

// Copyright 2022 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/cr_elements/cr_checkbox/cr_checkbox.js';
import 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/cr_elements/cr_expand_button/cr_expand_button.js';
import 'chrome://resources/cr_elements/cr_input/cr_input.js';
import 'chrome://resources/cr_elements/cr_url_list_item/cr_url_list_item.js';
import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.js';

import {CrLitElement} from '//resources/lit/v3_0/lit.rollup.js';
import type {PropertyValues} from '//resources/lit/v3_0/lit.rollup.js';
import type {CrCheckboxElement} from 'chrome://resources/cr_elements/cr_checkbox/cr_checkbox.js';
import type {CrInputElement} from 'chrome://resources/cr_elements/cr_input/cr_input.js';
import type {CrUrlListItemElement} from 'chrome://resources/cr_elements/cr_url_list_item/cr_url_list_item.js';
import {CrUrlListItemSize} from 'chrome://resources/cr_elements/cr_url_list_item/cr_url_list_item.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {getFolderLabel} from './power_bookmarks_utils.js';

import {getCss} from './power_bookmark_row.css.js';
import {getHtml} from './power_bookmark_row.html.js';
import type {PowerBookmarksService} from './power_bookmarks_service.js';

export const NESTED_BOOKMARKS_BASE_MARGIN = 45;
export const NESTED_BOOKMARKS_MARGIN_PER_DEPTH = 17;

export interface PowerBookmarkRowElement {
  $: {
    crUrlListItem: CrUrlListItemElement,
  };
}

export class PowerBookmarkRowElement extends CrLitElement {
  static get is() {
    return 'power-bookmark-row';
  }

  static override get styles() {
    return getCss();
  }

  override render() {
    return getHtml.bind(this)();
  }

  static override get properties() {
    return {
      bookmark: {type: Object},
      compact: {type: Boolean},
      bookmarksTreeViewEnabled: {type: Boolean},
      contextMenuBookmark: {type: Object},
      depth: {
        type: Number,
        reflect: true,
      },
      hasCheckbox: {
        type: Boolean,
        reflect: true,
      },
      renamingId: {type: String},
      imageUrls: {type: Object},
      searchQuery: {type: String},
      shoppingCollectionFolderId: {type: String},
      rowAriaDescription: {type: String},
      trailingIconTooltip: {type: String},
      listItemSize: {type: String},
      bookmarksService: {type: Object},
      toggleExpand: {type: Boolean},
      updatedElementIds: {type: Array},
    };
  }

  bookmark: chrome.bookmarks.BookmarkTreeNode;
  compact: boolean = false;
  contextMenuBookmark: chrome.bookmarks.BookmarkTreeNode|undefined;
  bookmarksTreeViewEnabled: boolean =
      loadTimeData.getBoolean('bookmarksTreeViewEnabled');
  depth: number = 0;
  forceHover: boolean = false;
  hasCheckbox: boolean = false;
  renamingId: string = '';
  searchQuery: string|undefined;
  shoppingCollectionFolderId: string = '';
  rowAriaDescription: string = '';
  trailingIconTooltip: string = '';
  toggleExpand: boolean = false;
  imageUrls: {[key: string]: string} = {};
  updatedElementIds: string[] = [];

  listItemSize: CrUrlListItemSize = CrUrlListItemSize.COMPACT;
  bookmarksService: PowerBookmarksService;

  override connectedCallback() {
    super.connectedCallback();
    this.onInputDisplayChange_();
    this.addEventListener('keydown', this.onKeydown_);
    this.addEventListener('focus', this.onFocus_);
  }

  override willUpdate(changedProperties: PropertyValues<this>) {
    super.willUpdate(changedProperties);

    if (changedProperties.has('compact')) {
      this.listItemSize =
          this.compact ? CrUrlListItemSize.COMPACT : CrUrlListItemSize.LARGE;
      if (this.bookmarksTreeViewEnabled && this.compact) {
        // Set custom margins for nested bookmarks in tree view.
        this.style.setProperty(
            '--base-margin', `${NESTED_BOOKMARKS_BASE_MARGIN}px`);
        this.style.setProperty(
            '--margin-per-depth', `${NESTED_BOOKMARKS_MARGIN_PER_DEPTH}px`);
      }
    }
  }

  override updated(changedProperties: PropertyValues<this>) {
    super.updated(changedProperties);

    if (changedProperties.has('renamingId') ||
        changedProperties.has('bookmark')) {
      if (this.renamingId === this.bookmark?.id) {
        this.onInputDisplayChange_();
      }
    }
    if (changedProperties.has('listItemSize')) {
      this.handleListItemSizeChanged_();
    }
    if (changedProperties.has('depth')) {
      this.style.setProperty('--depth', `${this.depth}`);
    }
  }

  override shouldUpdate(changedProperties: PropertyValues<this>) {
    if (changedProperties.has('updatedElementIds')) {
        const updatedElementIds = changedProperties.get('updatedElementIds');
        if (updatedElementIds?.includes(this.bookmark?.id)) {
            return true;
        }
        changedProperties.delete('updatedElementIds');
    }
    return super.shouldUpdate(changedProperties);
}

  override async getUpdateComplete() {
    // Wait for all children to update before marking as complete.
    const result = await super.getUpdateComplete();
    const children = [...this.shadowRoot!.querySelectorAll<CrLitElement>(
        'power-bookmark-row')];
    await Promise.all(children.map(el => el.updateComplete));
    return result;
  }

  override focus() {
    this.$.crUrlListItem.focus();
  }

  private onKeydown_(e: KeyboardEvent) {
    if (this.shadowRoot!.activeElement !== this.$.crUrlListItem) {
      return;
    }
    if (e.shiftKey && e.key === 'Tab') {
      // Hitting shift tab from CrUrlListItem to traverse focus backwards will
      // attempt to move focus to this element, which is responsible for
      // delegating focus but should itself not be focusable. So when the user
      // hits shift tab, immediately hijack focus onto itself so that the
      // browser moves focus to the focusable element before it once it
      // processes the shift tab.
      super.focus();
    } else if (e.key === 'Enter') {
      // Prevent iron-list from moving focus.
      e.stopPropagation();
    }
  }

  getBookmarkDescriptionForTests(bookmark: chrome.bookmarks.BookmarkTreeNode) {
    return this.getBookmarkDescription_(bookmark);
  }

  private onFocus_(e: FocusEvent) {
    if (e.composedPath()[0] === this && this.matches(':focus-visible')) {
      // If trying to directly focus on this row, move the focus to the
      // <cr-url-list-item>. Otherwise, UI might be trying to directly focus on
      // a specific child (eg. the input).
      // This should only be done when focusing via keyboard, to avoid blocking
      // drag interactions.
      this.$.crUrlListItem.focus();
    }
  }

  protected async handleListItemSizeChanged_() {
    await this.$.crUrlListItem.updateComplete;
    this.dispatchEvent(new CustomEvent('list-item-size-changed', {
      bubbles: true,
      composed: true,
    }));
  }

  protected renamingItem_(id: string) {
    return id === this.renamingId;
  }

  protected isCheckboxChecked_(): boolean {
    return !!this.bookmarksService?.bookmarkIsSelected(this.bookmark);
  }

  protected isBookmarksBar_(): boolean {
    return this.bookmark?.id === loadTimeData.getString('bookmarksBarId');
  }

  protected showTrailingIcon_(): boolean {
    return !this.renamingItem_(this.bookmark?.id) && !this.hasCheckbox;
  }

  protected onExpandedChanged_(event: CustomEvent<{value: boolean}>) {
    event.preventDefault();
    event.stopPropagation();
    this.toggleExpand = event.detail.value;
    this.dispatchEvent(new CustomEvent('power-bookmark-toggle', {
      bubbles: true,
      composed: true,
      detail: {
        bookmark: this.bookmark,
        expanded: this.toggleExpand,
        event: event,
      },
    }));
  }

  private onInputDisplayChange_() {
    const input = this.shadowRoot!.querySelector<CrInputElement>('#input');
    if (input) {
      input.select();
    }
  }

  /**
   * Dispatches a custom click event when the user clicks anywhere on the row.
   */
  protected onRowClicked_(event: MouseEvent) {
    // Ignore clicks on the row when it has an input, to ensure the row doesn't
    // eat input clicks. Also ignore clicks if the row has no associated
    // bookmark, or if the event is a right-click.
    if (this.renamingItem_(this.bookmark?.id) || !this.bookmark ||
        event.button === 2) {
      return;
    }
    // In compact view, if the item is a folder, ignore row clicks to toggle
    // the folder.
    if (this.shouldExpand_() && !this.hasCheckbox) {
      return;
    }
    event.preventDefault();
    event.stopPropagation();
    if (this.hasCheckbox && this.canEdit_(this.bookmark)) {
      // Clicking the row should trigger a checkbox click rather than a
      // standard row click.
      const checkbox =
          this.shadowRoot!.querySelector<CrCheckboxElement>('#checkbox')!;
      checkbox.checked = !checkbox.checked;
      return;
    }
    this.dispatchEvent(new CustomEvent('row-clicked', {
      bubbles: true,
      composed: true,
      detail: {
        bookmark: this.bookmark,
        event: event,
      },
    }));
  }

  /**
   * Dispatches a custom click event when the user right-clicks anywhere on the
   * row.
   */
  protected onContextMenu_(event: MouseEvent) {
    event.preventDefault();
    event.stopPropagation();
    this.dispatchEvent(new CustomEvent('context-menu', {
      bubbles: true,
      composed: true,
      detail: {
        bookmark: this.bookmark,
        event: event,
      },
    }));
  }

  /**
   * Dispatches a custom click event when the user clicks anywhere on the
   * trailing icon button.
   */
  protected onTrailingIconClicked_(event: MouseEvent) {
    event.preventDefault();
    event.stopPropagation();
    this.dispatchEvent(new CustomEvent('trailing-icon-clicked', {
      bubbles: true,
      composed: true,
      detail: {
        bookmark: this.bookmark,
        event: event,
      },
    }));
  }

  /**
   * Dispatches a custom click event when the user clicks on the checkbox.
   */
  protected onCheckboxChange_(event: Event) {
    event.preventDefault();
    event.stopPropagation();
    this.dispatchEvent(new CustomEvent('checkbox-change', {
      bubbles: true,
      composed: true,
      detail: {
        bookmark: this.bookmark,
        checked: (event.target as CrCheckboxElement).checked,
      },
    }));
  }

  /**
   * Triggers an input change event on enter. Extends default input behavior
   * which only triggers a change event if the value of the input has changed.
   */
  protected onInputKeyDown_(event: KeyboardEvent) {
    if (event.key === 'Enter') {
      event.stopPropagation();
      this.onInputChange_(event);
    }
  }

  private createInputChangeEvent_(value: string|null) {
    return new CustomEvent('input-change', {
      bubbles: true,
      composed: true,
      detail: {
        bookmark: this.bookmark,
        value: value,
      },
    });
  }

  /**
   * Triggers a custom input change event when the user hits enter or the input
   * loses focus.
   */
  protected onInputChange_(event: Event) {
    event.preventDefault();
    event.stopPropagation();
    const inputElement =
        this.shadowRoot!.querySelector<CrInputElement>('#input')!;
    this.dispatchEvent(this.createInputChangeEvent_(inputElement.value));
  }

  protected onInputBlur_(event: Event) {
    event.preventDefault();
    event.stopPropagation();
    this.dispatchEvent(this.createInputChangeEvent_(null));
  }

  protected isPriceTracked_(bookmark: chrome.bookmarks.BookmarkTreeNode):
      boolean {
    return !!this.bookmarksService?.getPriceTrackedInfo(bookmark);
  }

  /**
   * Whether the given price-tracked bookmark should display as if discounted.
   */
  protected showDiscountedPrice_(bookmark: chrome.bookmarks.BookmarkTreeNode):
      boolean {
    const bookmarkProductInfo =
        this.bookmarksService?.getPriceTrackedInfo(bookmark);
    if (bookmarkProductInfo) {
      return bookmarkProductInfo.info.previousPrice.length > 0;
    }
    return false;
  }

  protected getCurrentPrice_(bookmark: chrome.bookmarks.BookmarkTreeNode):
      string {
    const bookmarkProductInfo =
        this.bookmarksService?.getPriceTrackedInfo(bookmark);
    if (bookmarkProductInfo) {
      return bookmarkProductInfo.info.currentPrice;
    } else {
      return '';
    }
  }

  protected getPreviousPrice_(bookmark: chrome.bookmarks.BookmarkTreeNode):
      string {
    const bookmarkProductInfo =
        this.bookmarksService?.getPriceTrackedInfo(bookmark);
    if (bookmarkProductInfo) {
      return bookmarkProductInfo.info.previousPrice;
    } else {
      return '';
    }
  }

  protected getBookmarkForceHover_(bookmark: chrome.bookmarks.BookmarkTreeNode):
      boolean {
    return bookmark === this.contextMenuBookmark;
  }

  protected shouldExpand_(): boolean|undefined {
    return this.bookmark?.children && this.bookmarksTreeViewEnabled &&
        this.compact;
  }

  protected canEdit_(bookmark: chrome.bookmarks.BookmarkTreeNode): boolean {
    return bookmark?.id !== loadTimeData.getString('bookmarksBarId') &&
        bookmark?.id !== loadTimeData.getString('managedBookmarksFolderId');
  }

  protected isShoppingCollection_(bookmark: chrome.bookmarks.BookmarkTreeNode):
      boolean {
    return bookmark?.id === this.shoppingCollectionFolderId;
  }

  protected getBookmarkDescription_(
      bookmark: chrome.bookmarks.BookmarkTreeNode): string|undefined {
    if (this.compact) {
      if (bookmark?.url) {
        return undefined;
      }
      const count = bookmark?.children ? bookmark?.children.length : 0;
      return loadTimeData.getStringF('bookmarkFolderChildCount', count);
    } else {
      let urlString;
      if (bookmark?.url) {
        const url = new URL(bookmark?.url);
        // Show chrome:// if it's a chrome internal url
        if (url.protocol === 'chrome:') {
          urlString = 'chrome://' + url.hostname;
        }
        urlString = url.hostname;
      }
      if (urlString && this.searchQuery && bookmark?.parentId) {
        const parentFolder =
            this.bookmarksService.findBookmarkWithId(bookmark?.parentId);
        const folderLabel = getFolderLabel(parentFolder);
        return loadTimeData.getStringF(
            'urlFolderDescription', urlString, folderLabel);
      }
      return urlString;
    }
  }

  protected getBookmarkImageUrls_(bookmark: chrome.bookmarks.BookmarkTreeNode):
      string[] {
    const imageUrls: string[] = [];
    if (bookmark?.url) {
      const imageUrl = Object.entries(this.imageUrls)
                           .find(([key, _val]) => key === bookmark.id)
                           ?.[1];
      if (imageUrl) {
        imageUrls.push(imageUrl);
      }
    } else if (
        this.canEdit_(bookmark) && bookmark?.children &&
        !this.isShoppingCollection_(bookmark)) {
      bookmark?.children.forEach((child) => {
        const childImageUrl: string =
            Object.entries(this.imageUrls)
                .find(([key, _val]) => key === child.id)
                ?.[1]!;
        if (childImageUrl) {
          imageUrls.push(childImageUrl);
        }
      });
    }
    return imageUrls;
  }

  protected getBookmarkMenuA11yLabel_(url: string|undefined, title: string):
      string {
    if (url) {
      return loadTimeData.getStringF('bookmarkMenuLabel', title);
    } else {
      return loadTimeData.getStringF('folderMenuLabel', title);
    }
  }

  protected getBookmarkA11yLabel_(url: string|undefined, title: string):
      string {
    if (this.hasCheckbox) {
      if (this.isCheckboxChecked_()) {
        if (url) {
          return loadTimeData.getStringF('deselectBookmarkLabel', title);
        }
        return loadTimeData.getStringF('deselectFolderLabel', title);
      } else {
        if (url) {
          return loadTimeData.getStringF('selectBookmarkLabel', title);
        }
        return loadTimeData.getStringF('selectFolderLabel', title);
      }
    }
    if (url) {
      return loadTimeData.getStringF('openBookmarkLabel', title);
    }
    return loadTimeData.getStringF('openFolderLabel', title);
  }

  protected getBookmarkA11yDescription_(
      bookmark: chrome.bookmarks.BookmarkTreeNode): string {
    let description = '';
    if (this.bookmarksService?.getPriceTrackedInfo(bookmark)) {
      description += loadTimeData.getStringF(
          'a11yDescriptionPriceTracking', this.getCurrentPrice_(bookmark));
      const previousPrice = this.getPreviousPrice_(bookmark);
      if (previousPrice) {
        description += ' ' + loadTimeData.getStringF(
            'a11yDescriptionPriceChange', previousPrice);
      }
    }
    return description;
  }

  protected getBookmarkDescriptionMeta_(bookmark:
                                            chrome.bookmarks.BookmarkTreeNode) {
    // If there is a price available for the product and it isn't being
    // tracked, return the current price which will be added to the description
    // meta section.
    const productInfo =
        this.bookmarksService?.getAvailableProductInfo(bookmark);
    if (productInfo && productInfo.info.currentPrice &&
        !this.isPriceTracked_(bookmark)) {
      return productInfo.info.currentPrice;
    }

    return '';
  }
}


declare global {
  interface HTMLElementTagNameMap {
    'power-bookmark-row': PowerBookmarkRowElement;
  }
}

customElements.define(PowerBookmarkRowElement.is, PowerBookmarkRowElement);