blob: 0ac37f448c41e026a127d6d4507719e43010c472 [file] [log] [blame]
// Copyright 2024 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_button/cr_button.js';
import 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/cr_elements/icons.html.js';
import '/strings.m.js';
import '../tab_search_item.js';
import {getInstance as getAnnouncerInstance} from 'chrome://resources/cr_elements/cr_a11y_announcer/cr_a11y_announcer.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import type {PropertyValues} from 'chrome://resources/lit/v3_0/lit.rollup.js';
import {CrLitElement} from 'chrome://resources/lit/v3_0/lit.rollup.js';
import {normalizeURL, TabData, TabItemType} from '../tab_data.js';
import type {Tab, UnusedTabInfo} from '../tab_search.mojom-webui.js';
import {DeclutterCTREvent} from '../tab_search.mojom-webui.js';
import type {TabSearchApiProxy} from '../tab_search_api_proxy.js';
import {TabSearchApiProxyImpl} from '../tab_search_api_proxy.js';
import {TabSearchItemElement} from '../tab_search_item.js';
import {getCss} from './declutter_page.css.js';
import {getHtml} from './declutter_page.html.js';
const MINIMUM_SCROLLABLE_MAX_HEIGHT: number = 238;
const NON_SCROLLABLE_VERTICAL_SPACING: number = 164;
export class DeclutterPageElement extends CrLitElement {
static get is() {
return 'declutter-page';
}
static override get properties() {
return {
availableHeight: {type: Number},
showBackButton: {type: Boolean},
staleTabDatas_: {type: Array},
duplicateTabDatas_: {type: Array},
dedupeEnabled: {
type: Boolean,
reflect: true,
},
};
}
accessor availableHeight: number = 0;
accessor showBackButton: boolean = false;
accessor dedupeEnabled: boolean = loadTimeData.getBoolean('dedupeEnabled');
protected accessor staleTabDatas_: TabData[] = [];
protected accessor duplicateTabDatas_: TabData[] = [];
private apiProxy_: TabSearchApiProxy = TabSearchApiProxyImpl.getInstance();
private listenerIds_: number[] = [];
private visibilityChangedListener_: () => void;
static override get styles() {
return getCss();
}
constructor() {
super();
this.visibilityChangedListener_ = () => {
if (document.visibilityState === 'visible') {
this.apiProxy_.getUnusedTabs().then(
({tabs}) => this.setUnusedTabs_(tabs));
}
};
}
override render() {
return getHtml.bind(this)();
}
override connectedCallback() {
super.connectedCallback();
this.apiProxy_.getUnusedTabs().then(({tabs}) => this.setUnusedTabs_(tabs));
const callbackRouter = this.apiProxy_.getCallbackRouter();
this.listenerIds_.push(callbackRouter.unusedTabsChanged.addListener(
this.setUnusedTabs_.bind(this)));
document.addEventListener(
'visibilitychange', this.visibilityChangedListener_);
}
override disconnectedCallback() {
super.disconnectedCallback();
this.listenerIds_.forEach(
id => this.apiProxy_.getCallbackRouter().removeListener(id));
document.removeEventListener(
'visibilitychange', this.visibilityChangedListener_);
}
override updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
const changedPrivateProperties =
changedProperties as Map<PropertyKey, unknown>;
if (changedPrivateProperties.has('availableHeight')) {
this.onAvailableHeightChange_();
}
if (changedPrivateProperties.has('staleTabDatas_')) {
this.maybeAddScrollListener_();
this.updateScroll_();
}
}
override firstUpdated() {
this.maybeAddScrollListener_();
}
override focus() {
if (this.showBackButton) {
const backButton = this.shadowRoot.querySelector('cr-icon-button')!;
backButton.focus();
} else {
super.focus();
}
}
logCtrValue(event: DeclutterCTREvent) {
chrome.metricsPrivate.recordEnumerationValue(
'Tab.Organization.DeclutterCTR', event,
DeclutterCTREvent.MAX_VALUE + 1);
}
private getMaxScrollableHeight_(): number {
return Math.max(
MINIMUM_SCROLLABLE_MAX_HEIGHT,
(this.availableHeight - NON_SCROLLABLE_VERTICAL_SPACING));
}
private onAvailableHeightChange_() {
const scrollable = this.shadowRoot.querySelector('#scrollable');
if (scrollable) {
this.updateScroll_();
}
}
private maybeAddScrollListener_() {
const scrollable = this.shadowRoot.querySelector('#scrollable');
if (scrollable) {
scrollable.addEventListener('scroll', this.updateScroll_.bind(this));
}
}
private async updateScroll_() {
await this.updateComplete;
const scrollable =
this.shadowRoot.querySelector<HTMLElement>('#scrollable');
if (scrollable) {
const maxHeight = this.getMaxScrollableHeight_();
scrollable.style.maxHeight = maxHeight + 'px';
scrollable.classList.toggle(
'can-scroll', scrollable.clientHeight < scrollable.scrollHeight);
scrollable.classList.toggle('is-scrolled', scrollable.scrollTop > 0);
scrollable.classList.toggle(
'scrolled-to-bottom',
scrollable.scrollTop + maxHeight >= scrollable.scrollHeight);
}
}
protected getBackButtonAriaLabel_(): string {
return loadTimeData.getStringF(
'backButtonAriaLabel',
loadTimeData.getString(
this.dedupeEnabled ? 'declutterTitle' :
'declutterInactiveTitleNoDedupe'));
}
protected getCloseButtonAriaLabel_(tabData: TabData): string {
return loadTimeData.getStringF(
'declutterCloseTabAriaLabel', tabData.tab.title,
tabData.tab.lastActiveElapsedText);
}
protected getCloseButtonTooltip_(): string {
return loadTimeData.getString('declutterCloseTabTooltip');
}
protected onBackClick_() {
this.fire('back-click');
}
protected onCloseTabsClick_() {
const tabIds = this.staleTabDatas_.map((tabData) => tabData.tab.tabId);
const urls = this.duplicateTabDatas_.map((tabData) => tabData.tab.url);
this.apiProxy_.declutterTabs(tabIds, urls);
this.logCtrValue(DeclutterCTREvent.kCloseTabsClicked);
}
protected onTabFocus_(e: FocusEvent) {
if (e.target instanceof TabSearchItemElement) {
const tabSearchItem: TabSearchItemElement = e.target;
tabSearchItem.classList.toggle('selected', true);
const closeButton =
tabSearchItem.shadowRoot.querySelector('#closeButton')!;
closeButton.setAttribute('aria-selected', 'true');
} else {
throw new Error('Invalid onTabFocus_ target type: ' + typeof e.target);
}
}
protected onTabBlur_(e: FocusEvent) {
if (e.target instanceof TabSearchItemElement) {
const tabSearchItem: TabSearchItemElement = e.target;
tabSearchItem.classList.toggle('selected', false);
const closeButton =
tabSearchItem.shadowRoot.querySelector('#closeButton')!;
closeButton.setAttribute('aria-selected', 'false');
} else {
throw new Error('Invalid onTabBlur_ target type: ' + typeof e.target);
}
}
protected onTabKeyDown_(e: KeyboardEvent) {
if ((e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) {
return;
}
const tabSearchItems =
Array.from(this.shadowRoot.querySelectorAll('tab-search-item'));
const tabSearchItemCount = tabSearchItems.length;
const focusedIndex =
tabSearchItems.findIndex((element) => element.matches(':focus'));
if (focusedIndex < 0) {
return;
}
let nextFocusedIndex = 0;
if (e.key === 'ArrowUp') {
nextFocusedIndex =
(focusedIndex + tabSearchItemCount - 1) % tabSearchItemCount;
} else if (e.key === 'ArrowDown') {
nextFocusedIndex = (focusedIndex + 1) % tabSearchItemCount;
}
const selectedItem = tabSearchItems[nextFocusedIndex]!;
const focusableElement =
selectedItem.shadowRoot.querySelector(`cr-icon-button`)!;
focusableElement.focus();
e.preventDefault();
e.stopPropagation();
}
protected onStaleTabExclude_(e: Event) {
const tabData = (e.currentTarget as TabSearchItemElement).data;
this.apiProxy_.excludeFromStaleTabs(tabData.tab.tabId);
this.announceTabExcluded_();
}
protected onDuplicateTabExclude_(e: Event) {
const tabData = (e.currentTarget as TabSearchItemElement).data;
this.apiProxy_.excludeFromDuplicateTabs(tabData.tab.url);
this.announceTabExcluded_();
}
private announceTabExcluded_() {
getAnnouncerInstance().announce(
loadTimeData.getString('a11yTabExcludedFromList'));
}
private setUnusedTabs_(tabs: UnusedTabInfo) {
this.setStaleTabs_(tabs.staleTabs);
this.setDuplicateTabs_(tabs.duplicateTabs);
}
private setStaleTabs_(tabs: Tab[]): void {
this.staleTabDatas_ = tabs.map((tab) => this.tabDataFromTab_(tab));
}
private setDuplicateTabs_(tabs: {[key: string]: Tab[]}): void {
this.duplicateTabDatas_ = [];
for (const url in tabs) {
const urlTabs = tabs[url]!;
// `tabs` include all the tabs with the url. The duplicate tab count
// should be one less than the count of `tabs`.
const duplicateCount = urlTabs.length - 1;
if (duplicateCount > 0) {
const tabData: TabData = this.tabDataFromTab_(urlTabs[0]!);
if (duplicateCount === 1) {
tabData.tab.title =
loadTimeData.getStringF('duplicateItemTitleSingle', url);
} else {
tabData.tab.title = loadTimeData.getStringF(
'duplicateItemTitleMulti', url, duplicateCount);
}
this.duplicateTabDatas_.push(tabData);
}
}
}
private tabDataFromTab_(tab: Tab): TabData {
return new TabData(
tab, TabItemType.OPEN_TAB, new URL(normalizeURL(tab.url.url)).hostname);
}
}
declare global {
interface HTMLElementTagNameMap {
'declutter-page': DeclutterPageElement;
}
}
customElements.define(DeclutterPageElement.is, DeclutterPageElement);