blob: 6b620a97cc08137b3631baab44a6389336e91a69 [file] [log] [blame]
// 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 '//bookmarks-side-panel.top-chrome/shared/sp_list_item_badge.js';
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/cr_elements/cr_icon/cr_icon.js';
import type {PriceTrackingBrowserProxy} from '//resources/cr_components/commerce/price_tracking_browser_proxy.js';
import {PriceTrackingBrowserProxyImpl} from '//resources/cr_components/commerce/price_tracking_browser_proxy.js';
import type {BookmarkProductInfo} from '//resources/cr_components/commerce/shared.mojom-webui.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 {isRTL} from 'chrome://resources/js/util.js';
import type {BookmarksTreeNode} from './bookmarks.mojom-webui.js';
import {KeyArrowNavigationService} from './keyboard_arrow_navigation_service.js';
import {getCss} from './power_bookmark_row.css.js';
import {getHtml} from './power_bookmark_row.html.js';
import {PowerBookmarksService} from './power_bookmarks_service.js';
import {getFolderLabel} from './power_bookmarks_utils.js';
export const NESTED_BOOKMARKS_BASE_MARGIN = 28;
export const NESTED_BOOKMARKS_MARGIN_PER_DEPTH = 12;
export const BOOKMARK_ROW_LOAD_EVENT = 'bookmark-row-connected-event';
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,
},
selectedBookmarks: {type: Array},
renamingId: {type: String},
imageUrls: {type: Object},
isPriceTracked: {type: Boolean},
searchQuery: {type: String},
shoppingCollectionFolderId: {type: String},
rowAriaDescription: {type: String},
trailingIconTooltip: {type: String},
listItemSize: {type: String},
toggleExpand: {type: Boolean},
isSelected: {type: Boolean},
updatedElementIds: {type: Array},
canDrag: {type: Boolean},
hasActiveDrag: {type: Boolean},
activeFolderPath: {type: Array},
hasFolders: {type: Boolean, reflect: true},
sortedChildren: {type: Array},
activeSortIndex: {type: Number},
};
}
accessor bookmark: BookmarksTreeNode = {
id: '',
parentId: '',
index: 0,
title: '',
url: null,
dateAdded: null,
dateLastUsed: null,
unmodifiable: false,
children: null,
};
accessor compact: boolean = false;
accessor contextMenuBookmark: BookmarksTreeNode|undefined;
accessor bookmarksTreeViewEnabled: boolean =
loadTimeData.getBoolean('bookmarksTreeViewEnabled');
accessor depth: number = 0;
accessor hasCheckbox: boolean = false;
accessor selectedBookmarks: BookmarksTreeNode[] = [];
accessor renamingId: string = '';
accessor searchQuery: string|undefined;
accessor shoppingCollectionFolderId: string = '';
accessor rowAriaDescription: string = '';
accessor trailingIconTooltip: string = '';
accessor toggleExpand: boolean = false;
accessor isSelected: boolean = false;
accessor imageUrls: {[key: string]: string} = {};
accessor updatedElementIds: string[] = [];
accessor isPriceTracked: boolean = false;
accessor canDrag: boolean = true;
accessor hasActiveDrag: boolean = false;
accessor activeFolderPath: BookmarksTreeNode[] = [];
accessor hasFolders: boolean = false;
accessor sortedChildren: BookmarksTreeNode[] = [];
accessor activeSortIndex: number = 0;
accessor listItemSize: CrUrlListItemSize = CrUrlListItemSize.COMPACT;
private bookmarksService_: PowerBookmarksService =
PowerBookmarksService.getInstance();
private priceTrackingProxy_: PriceTrackingBrowserProxy =
PriceTrackingBrowserProxyImpl.getInstance();
private shoppingListenerIds_: number[] = [];
private keyArrowNavigationService_: KeyArrowNavigationService =
KeyArrowNavigationService.getInstance();
override connectedCallback() {
super.connectedCallback();
this.onInputDisplayChange_();
this.addEventListener('keydown', this.onKeydown_);
this.addEventListener('focus', this.onFocus_);
this.isPriceTracked = this.isPriceTracked_();
const callbackRouter = this.priceTrackingProxy_.getCallbackRouter();
this.shoppingListenerIds_.push(
callbackRouter.priceTrackedForBookmark.addListener(
(product: BookmarkProductInfo) =>
this.handleBookmarkSubscriptionChange_(product, true)),
callbackRouter.priceUntrackedForBookmark.addListener(
(product: BookmarkProductInfo) =>
this.handleBookmarkSubscriptionChange_(product, false)),
);
this.fire(BOOKMARK_ROW_LOAD_EVENT);
}
override disconnectedCallback() {
this.shoppingListenerIds_.forEach(
id => this.priceTrackingProxy_.getCallbackRouter().removeListener(id));
}
protected getUrl_(): string|undefined {
return this.bookmark.url || undefined;
}
private handleBookmarkSubscriptionChange_(
product: BookmarkProductInfo, subscribed: boolean): void {
if (product.bookmarkId.toString() === this.bookmark.id) {
this.isPriceTracked = subscribed;
}
}
override willUpdate(changedProperties: PropertyValues<this>) {
super.willUpdate(changedProperties);
if (changedProperties.has('bookmark') &&
this.bookmark.id !== changedProperties.get('bookmark')?.id) {
this.toggleExpand = false;
this.sortedChildren =
this.bookmark.children ? [...this.bookmark.children] : [];
this.bookmarksService_.sortBookmarks(
this.sortedChildren, this.activeSortIndex);
}
if (changedProperties.has('activeFolderPath')) {
this.isSelected = this.activeFolderPath?.length > 0 &&
this.activeFolderPath[this.activeFolderPath.length - 1].id ===
this.bookmark.id;
}
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`);
}
}
if (changedProperties.has('activeSortIndex')) {
this.bookmarksService_.sortBookmarks(
this.sortedChildren, this.activeSortIndex);
}
}
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.currentUrlListItem_.focus();
}
private setExpanded_(expanded: boolean, event?: Event) {
if (!this.isFolder_() || this.toggleExpand === expanded) {
return;
}
this.toggleExpand = expanded;
if (!this.toggleExpand) {
this.keyArrowNavigationService_.removeElementsWithin(this);
}
this.dispatchEvent(new CustomEvent('power-bookmark-toggle', {
bubbles: true,
composed: true,
detail: {
bookmark: this.bookmark,
expanded: this.toggleExpand,
event: event,
},
}));
}
private onKeydown_(e: KeyboardEvent) {
if (this.shadowRoot.activeElement !== this.currentUrlListItem_) {
return;
}
const isRtl = isRTL();
const forwardKey = isRtl ? 'ArrowLeft' : 'ArrowRight';
const backwardKey = isRtl ? 'ArrowRight' : 'ArrowLeft';
if (e.key === forwardKey) {
if (this.isFolder_()) {
if (!this.toggleExpand) {
this.setExpanded_(true);
} else if (this.isFolderWithChildren_()) {
this.keyArrowNavigationService_.moveFocus(1);
}
}
e.stopPropagation();
return;
}
if (e.key === backwardKey) {
if (this.isFolder_() && this.toggleExpand) {
this.setExpanded_(false);
} else {
const parentRow =
(this.getRootNode() as ShadowRoot)?.host as HTMLElement;
parentRow.focus();
this.keyArrowNavigationService_.setCurrentFocusIndex(parentRow);
}
e.stopPropagation();
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: BookmarksTreeNode) {
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.currentUrlListItem_.focus();
}
}
get currentUrlListItem_(): CrLitElement&CrUrlListItemElement {
return this.shadowRoot.querySelector<CrLitElement&CrUrlListItemElement>(
'#crUrlListItem')!;
}
protected async handleListItemSizeChanged_() {
await this.currentUrlListItem_.updateComplete;
this.dispatchEvent(new CustomEvent('list-item-size-changed', {
bubbles: true,
composed: true,
}));
}
protected isRenamingItem_(): boolean {
return this.bookmark.id === this.renamingId;
}
protected isCheckboxChecked_(): boolean {
return this.selectedBookmarks.includes(this.bookmark);
}
protected isBookmarksBar_(): boolean {
return this.bookmark?.id === loadTimeData.getString('bookmarksBarId');
}
protected showTrailingIcon_(): boolean {
return !this.isRenamingItem_() && !this.hasCheckbox;
}
protected onExpandedChanged_(event: CustomEvent<{value: boolean}>) {
event.preventDefault();
event.stopPropagation();
this.setExpanded_(event.detail.value, 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.isRenamingItem_() || !this.bookmark || event.button === 2 ||
this.hasActiveDrag) {
return;
}
event.preventDefault();
event.stopPropagation();
if (this.hasCheckbox && this.canEdit_()) {
// 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');
if (inputElement) {
this.dispatchEvent(this.createInputChangeEvent_(inputElement.value));
}
}
protected onInputBlur_(event: Event) {
event.preventDefault();
event.stopPropagation();
this.dispatchEvent(this.createInputChangeEvent_(null));
}
private isPriceTracked_(): boolean {
return !!this.bookmarksService_.getPriceTrackedInfo(this.bookmark);
}
/**
* Whether the given price-tracked bookmark should display as if discounted.
*/
protected showDiscountedPrice_(): boolean {
const bookmarkProductInfo =
this.bookmarksService_.getPriceTrackedInfo(this.bookmark);
if (bookmarkProductInfo) {
return bookmarkProductInfo.info.previousPrice.length > 0;
}
return false;
}
protected getCurrentPrice_(bookmark: BookmarksTreeNode): string {
const bookmarkProductInfo =
this.bookmarksService_.getPriceTrackedInfo(bookmark);
if (bookmarkProductInfo) {
return bookmarkProductInfo.info.currentPrice;
} else {
return '';
}
}
protected getPreviousPrice_(bookmark: BookmarksTreeNode): string {
const bookmarkProductInfo =
this.bookmarksService_.getPriceTrackedInfo(bookmark);
if (bookmarkProductInfo) {
return bookmarkProductInfo.info.previousPrice;
} else {
return '';
}
}
protected getBookmarkForceHover_(): boolean {
return this.bookmark === this.contextMenuBookmark;
}
protected shouldExpand_(): boolean|null {
return this.bookmark?.children && this.bookmarksTreeViewEnabled &&
this.compact;
}
protected canEdit_(): boolean {
return this.bookmark?.id !== loadTimeData.getString('bookmarksBarId') &&
this.bookmark?.id !==
loadTimeData.getString('managedBookmarksFolderId');
}
protected isShoppingCollection_(): boolean {
return this.bookmark?.id === this.shoppingCollectionFolderId;
}
protected isFolder_(): boolean {
return !this.bookmark.url;
}
protected isFolderWithChildren_(): boolean {
return this.isFolder_() && !!this.bookmark.children?.length;
}
protected getBookmarkDescription_(bookmark: BookmarksTreeNode): 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_(): string[] {
const imageUrls: string[] = [];
if (this.bookmark?.url) {
const imageUrl = Object.entries(this.imageUrls)
.find(([key, _val]) => key === this.bookmark.id)
?.[1];
if (imageUrl) {
imageUrls.push(imageUrl);
}
} else if (
this.canEdit_() && this.bookmark?.children &&
!this.isShoppingCollection_()) {
this.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_(): string {
return loadTimeData.getStringF(
this.bookmark.url ? 'bookmarkMenuLabel' : 'folderMenuLabel',
this.bookmark.title);
}
protected getBookmarkA11yLabel_(): string {
if (this.hasCheckbox) {
if (this.isCheckboxChecked_()) {
return loadTimeData.getStringF(
this.bookmark.url ? 'deselectBookmarkLabel' : 'deselectFolderLabel',
this.bookmark.title);
}
return loadTimeData.getStringF(
this.bookmark.url ? 'selectBookmarkLabel' : 'selectFolderLabel',
this.bookmark.title);
}
return loadTimeData.getStringF(
this.bookmark.url ? 'openBookmarkLabel' : 'openFolderLabel',
this.bookmark.title);
}
protected getBookmarkA11yDescription_(): string {
const bookmark = this.bookmark;
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_() {
// 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(this.bookmark);
if (productInfo && productInfo.info.currentPrice &&
!this.isPriceTracked_()) {
return productInfo.info.currentPrice;
}
return '';
}
}
declare global {
interface HTMLElementTagNameMap {
'power-bookmark-row': PowerBookmarkRowElement;
}
}
customElements.define(PowerBookmarkRowElement.is, PowerBookmarkRowElement);