// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import './strings.m.js';
import './alert_indicators.js';
import {assert} from 'chrome://resources/js/assert.js';
import {CustomElement} from 'chrome://resources/js/custom_element.js';
import {getFavicon} from 'chrome://resources/js/icon.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {isRTL} from 'chrome://resources/js/util.js';
import type {AlertIndicatorsElement} from './alert_indicators.js';
import {getTemplate} from './tab.html.js';
import type {Tab} from './tab_strip.mojom-webui.js';
import {TabNetworkState} from './tab_strip.mojom-webui.js';
import {TabSwiper} from './tab_swiper.js';
import type {TabsApiProxy} from './tabs_api_proxy.js';
import {CloseTabAction, TabsApiProxyImpl} from './tabs_api_proxy.js';
function getAccessibleTitle(tab: Tab): string {
const tabTitle = tab.title;
if (tab.crashed) {
return loadTimeData.getStringF('tabCrashed', tabTitle);
}
if (tab.networkState === TabNetworkState.kError) {
return loadTimeData.getStringF('tabNetworkError', tabTitle);
}
return tabTitle;
}
/**
* TODO(crbug.com/40659171): padding-inline-end cannot be animated yet.
*/
function getPaddingInlineEndProperty(): string {
return isRTL() ? 'paddingLeft' : 'paddingRight';
}
export class TabElement extends CustomElement {
static override get template() {
return getTemplate();
}
private alertIndicatorsEl_: AlertIndicatorsElement;
private closeButtonEl_: HTMLElement;
private dragImageEl_: HTMLElement;
private tabEl_: HTMLElement;
private faviconEl_: HTMLElement;
private thumbnail_: HTMLImageElement;
private tab_: Tab|null = null;
private tabsApi_: TabsApiProxy;
private titleTextEl_: HTMLElement;
private isValidDragOverTarget_: boolean;
private tabSwiper_: TabSwiper;
private onTabActivating_: (tabId: number) => void;
constructor() {
super();
this.alertIndicatorsEl_ =
this.getRequiredElement('tabstrip-alert-indicators');
// Normally, custom elements will get upgraded automatically once added
// to the DOM, but TabElement may need to update properties on
// AlertIndicatorElement before this happens, so upgrade it manually.
customElements.upgrade(this.alertIndicatorsEl_);
this.closeButtonEl_ = this.getRequiredElement('#close');
this.closeButtonEl_.setAttribute(
'aria-label', loadTimeData.getString('closeTab'));
this.dragImageEl_ = this.getRequiredElement('#dragImage');
this.tabEl_ = this.getRequiredElement('#tab');
this.faviconEl_ = this.getRequiredElement('#favicon');
this.thumbnail_ =
this.getRequiredElement<HTMLImageElement>('#thumbnailImg');
this.tabsApi_ = TabsApiProxyImpl.getInstance();
this.titleTextEl_ = this.getRequiredElement('#titleText');
/**
* Flag indicating if this TabElement can accept dragover events. This
* is used to pause dragover events while animating as animating causes
* the elements below the pointer to shift.
*/
this.isValidDragOverTarget_ = true;
this.tabEl_.addEventListener('click', () => this.onClick_());
this.tabEl_.addEventListener('contextmenu', e => this.onContextMenu_(e));
this.tabEl_.addEventListener('keydown', e => this.onKeyDown_(e));
this.tabEl_.addEventListener('pointerup', e => this.onPointerUp_(e));
this.closeButtonEl_.addEventListener('click', e => this.onClose_(e));
this.addEventListener('swipe', () => this.onSwipe_());
this.tabSwiper_ = new TabSwiper(this);
this.onTabActivating_ = (_tabId: number) => {};
}
hasTabModel(): boolean {
return this.tab_ !== null;
}
get tab(): Tab {
assert(this.tab_);
return this.tab_;
}
set tab(tab: Tab) {
this.toggleAttribute('active', tab.active);
this.tabEl_.setAttribute('aria-selected', tab.active.toString());
this.toggleAttribute('hide-icon_', !tab.showIcon);
this.toggleAttribute(
'waiting_',
!tab.shouldHideThrobber &&
tab.networkState === TabNetworkState.kWaiting);
this.toggleAttribute(
'loading_',
!tab.shouldHideThrobber &&
tab.networkState === TabNetworkState.kLoading);
this.toggleAttribute('pinned', tab.pinned);
this.toggleAttribute('blocked_', tab.blocked);
this.setAttribute('draggable', String(true));
this.toggleAttribute('crashed_', tab.crashed);
if (tab.title) {
this.titleTextEl_.textContent = tab.title;
} else if (
!tab.shouldHideThrobber &&
(tab.networkState === TabNetworkState.kWaiting ||
tab.networkState === TabNetworkState.kLoading)) {
this.titleTextEl_.textContent = loadTimeData.getString('loadingTab');
} else {
this.titleTextEl_.textContent = loadTimeData.getString('defaultTabTitle');
}
this.titleTextEl_.setAttribute('aria-label', getAccessibleTitle(tab));
if (tab.networkState === TabNetworkState.kWaiting ||
(tab.networkState === TabNetworkState.kLoading &&
tab.isDefaultFavicon)) {
this.faviconEl_.style.backgroundImage = 'none';
} else if (tab.faviconUrl) {
this.faviconEl_.style.backgroundImage = `url(${
tab.active && tab.activeFaviconUrl ? tab.activeFaviconUrl.url :
tab.faviconUrl.url})`;
} else {
this.faviconEl_.style.backgroundImage = getFavicon('');
}
// Expose the ID to an attribute to allow easy querySelector use
this.setAttribute('data-tab-id', tab.id.toString());
this.alertIndicatorsEl_.updateAlertStates(tab.alertStates)
.then((alertIndicatorsCount) => {
this.toggleAttribute('has-alert-states_', alertIndicatorsCount > 0);
});
if (!this.tab_ || (this.tab_.pinned !== tab.pinned && !tab.pinned)) {
this.tabSwiper_.startObserving();
} else if (this.tab_.pinned !== tab.pinned && tab.pinned) {
this.tabSwiper_.stopObserving();
}
this.tab_ = Object.freeze(tab);
}
get isValidDragOverTarget(): boolean {
return !this.hasAttribute('dragging_') && this.isValidDragOverTarget_;
}
set isValidDragOverTarget(isValid: boolean) {
this.isValidDragOverTarget_ = isValid;
}
set onTabActivating(callback: (tabId: number) => void) {
this.onTabActivating_ = callback;
}
override focus() {
this.tabEl_.focus();
}
getDragImage(): HTMLElement {
return this.dragImageEl_;
}
getDragImageCenter(): HTMLElement {
// dragImageEl_ has padding, so the drag image should be centered relative
// to tabEl_, the element within the padding.
return this.tabEl_;
}
updateThumbnail(imgData: string) {
this.thumbnail_.src = imgData;
}
private onClick_() {
if (!this.tab_ || this.tabSwiper_.wasSwiping()) {
return;
}
const tabId = this.tab_.id;
this.onTabActivating_(tabId);
this.tabsApi_.activateTab(tabId);
this.setTouchPressed(false);
this.tabsApi_.closeContainer();
}
private onContextMenu_(event: Event) {
event.preventDefault();
event.stopPropagation();
}
private onClose_(event: Event) {
assert(this.tab_);
event.stopPropagation();
this.tabsApi_.closeTab(this.tab_.id, CloseTabAction.CLOSE_BUTTON);
}
private onSwipe_() {
assert(this.tab_);
this.tabsApi_.closeTab(this.tab_.id, CloseTabAction.SWIPED_TO_CLOSE);
}
private onKeyDown_(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ' ') {
this.onClick_();
}
}
private onPointerUp_(event: PointerEvent) {
event.stopPropagation();
if (event.pointerType !== 'touch' && event.button === 2) {
this.tabsApi_.showTabContextMenu(
this.tab.id, event.clientX, event.clientY);
}
}
resetSwipe() {
this.tabSwiper_.reset();
}
setDragging(isDragging: boolean) {
this.toggleAttribute('dragging_', isDragging);
}
setDraggedOut(isDraggedOut: boolean) {
this.toggleAttribute('dragged-out_', isDraggedOut);
}
isDraggedOut(): boolean {
return this.hasAttribute('dragged-out_');
}
setTouchPressed(isTouchPressed: boolean) {
this.toggleAttribute('touch_pressed_', isTouchPressed);
}
slideOut(): Promise<void> {
assert(this.tab_);
if (!this.tabsApi_.isVisible() || this.tab_.pinned ||
this.tabSwiper_.wasSwiping()) {
this.remove();
return Promise.resolve();
}
return new Promise(resolve => {
const finishCallback = () => {
this.remove();
resolve();
};
const translateAnimation = this.animate(
{
transform: ['translateY(0)', 'translateY(-100%)'],
},
{
duration: 150,
easing: 'cubic-bezier(.4, 0, 1, 1)',
fill: 'forwards',
});
const opacityAnimation = this.animate(
{
opacity: [1, 0],
},
{
delay: 97.5,
duration: 50,
fill: 'forwards',
});
const widthAnimationKeyframes = {
maxWidth: ['var(--tabstrip-tab-width)', 0],
[getPaddingInlineEndProperty()]: ['var(--tabstrip-tab-spacing)', 0],
};
// TODO(dpapad): Figure out why TypeScript compiler does not understand
// the alternative keyframe syntax. Seems to work in the TS playground.
const widthAnimation = this.animate(widthAnimationKeyframes as any, {
delay: 97.5,
duration: 300,
easing: 'cubic-bezier(.4, 0, 0, 1)',
fill: 'forwards',
});
const visibilityChangeListener = () => {
if (!this.tabsApi_.isVisible()) {
// If a tab strip becomes hidden during the animation, the onfinish
// event will not get fired until the tab strip becomes visible again.
// Therefore, when the tab strip becomes hidden, immediately call the
// finish callback.
translateAnimation.cancel();
opacityAnimation.cancel();
widthAnimation.cancel();
finishCallback();
}
};
document.addEventListener(
'visibilitychange', visibilityChangeListener, {once: true});
// The onfinish handler is put on the width animation, as it will end
// last.
widthAnimation.onfinish = () => {
document.removeEventListener(
'visibilitychange', visibilityChangeListener);
finishCallback();
};
});
}
}
declare global {
interface HTMLElementTagNameMap {
'tabstrip-tab': TabElement;
}
}
customElements.define('tabstrip-tab', TabElement);
export function isTabElement(element: Element): boolean {
return element.tagName === 'TABSTRIP-TAB';
}