blob: 898d909df646eccf97f18ceffcbb0e50e6b00431 [file] [log] [blame]
// Copyright 2019 The Chromium Authors. All rights reserved.
// 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 {assert} from 'chrome://resources/js/assert.m.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.m.js';
import {isRTL} from 'chrome://resources/js/util.m.js';
import {AlertIndicatorsElement} from './alert_indicators.js';
import {TabStripEmbedderProxy, TabStripEmbedderProxyImpl} from './tab_strip_embedder_proxy.js';
import {TabSwiper} from './tab_swiper.js';
import {CloseTabAction, TabData, TabNetworkState, TabsApiProxy, TabsApiProxyImpl} from './tabs_api_proxy.js';
const DEFAULT_ANIMATION_DURATION = 125;
/**
* @param {!TabData} tab
* @return {string}
*/
function getAccessibleTitle(tab) {
const tabTitle = tab.title;
if (tab.crashed) {
return loadTimeData.getStringF('tabCrashed', tabTitle);
}
if (tab.networkState === TabNetworkState.ERROR) {
return loadTimeData.getStringF('tabNetworkError', tabTitle);
}
return tabTitle;
}
/**
* TODO(crbug.com/1025390): padding-inline-end cannot be animated yet.
* @return {string}
*/
function getPaddingInlineEndProperty() {
return isRTL() ? 'paddingLeft' : 'paddingRight';
}
export class TabElement extends CustomElement {
static get template() {
return `{__html_template__}`;
}
constructor() {
super();
this.alertIndicatorsEl_ = /** @type {!AlertIndicatorsElement} */
(this.$('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_);
/** @private {!HTMLElement} */
this.closeButtonEl_ = /** @type {!HTMLElement} */ (this.$('#close'));
this.closeButtonEl_.setAttribute(
'aria-label', loadTimeData.getString('closeTab'));
/** @private {!HTMLElement} */
this.dragImageEl_ = /** @type {!HTMLElement} */ (this.$('#dragImage'));
/** @private {!HTMLElement} */
this.tabEl_ = /** @type {!HTMLElement} */ (this.$('#tab'));
/** @private {!HTMLElement} */
this.faviconEl_ = /** @type {!HTMLElement} */ (this.$('#favicon'));
/** @private {!HTMLElement} */
this.thumbnailContainer_ =
/** @type {!HTMLElement} */ (this.$('#thumbnail'));
/** @private {!Image} */
this.thumbnail_ = /** @type {!Image} */ (this.$('#thumbnailImg'));
/** @private {!TabData} */
this.tab_;
/** @private {!TabsApiProxy} */
this.tabsApi_ = TabsApiProxyImpl.getInstance();
/** @private {!TabStripEmbedderProxy} */
this.embedderApi_ = TabStripEmbedderProxyImpl.getInstance();
/** @private {!HTMLElement} */
this.titleTextEl_ = /** @type {!HTMLElement} */ (this.$('#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.
* @private {boolean}
*/
this.isValidDragOverTarget_ = true;
this.tabEl_.addEventListener('click', () => this.onClick_());
this.tabEl_.addEventListener('contextmenu', e => this.onContextMenu_(e));
this.tabEl_.addEventListener(
'keydown', e => this.onKeyDown_(/** @type {!KeyboardEvent} */ (e)));
this.tabEl_.addEventListener(
'pointerup', e => this.onPointerUp_(/** @type {!PointerEvent} */ (e)));
this.closeButtonEl_.addEventListener('click', e => this.onClose_(e));
this.addEventListener('swipe', () => this.onSwipe_());
/** @private @const {!TabSwiper} */
this.tabSwiper_ = new TabSwiper(this);
/** @private {!Function} */
this.onTabActivating_ = (tabId) => {};
}
/** @return {!TabData} */
get tab() {
return this.tab_;
}
/** @param {!TabData} tab */
set 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.WAITING);
this.toggleAttribute(
'loading_',
!tab.shouldHideThrobber &&
tab.networkState === TabNetworkState.LOADING);
this.toggleAttribute('pinned', tab.pinned);
this.toggleAttribute('blocked_', tab.blocked);
this.setAttribute('draggable', true);
this.toggleAttribute('crashed_', tab.crashed);
if (tab.title) {
this.titleTextEl_.textContent = tab.title;
} else if (
!tab.shouldHideThrobber &&
(tab.networkState === TabNetworkState.WAITING ||
tab.networkState === TabNetworkState.LOADING)) {
this.titleTextEl_.textContent = loadTimeData.getString('loadingTab');
} else {
this.titleTextEl_.textContent = loadTimeData.getString('defaultTabTitle');
}
this.titleTextEl_.setAttribute('aria-label', getAccessibleTitle(tab));
if (tab.networkState === TabNetworkState.WAITING ||
(tab.networkState === TabNetworkState.LOADING &&
tab.isDefaultFavicon)) {
this.faviconEl_.style.backgroundImage = 'none';
} else if (tab.favIconUrl) {
this.faviconEl_.style.backgroundImage = `url(${tab.favIconUrl})`;
} else {
this.faviconEl_.style.backgroundImage = getFavicon('');
}
// Expose the ID to an attribute to allow easy querySelector use
this.setAttribute('data-tab-id', tab.id);
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);
}
/** @return {boolean} */
get isValidDragOverTarget() {
return !this.hasAttribute('dragging_') && this.isValidDragOverTarget_;
}
/** @param {boolean} isValid */
set isValidDragOverTarget(isValid) {
this.isValidDragOverTarget_ = isValid;
}
/** @param {!Function} callback */
set onTabActivating(callback) {
this.onTabActivating_ = callback;
}
focus() {
this.tabEl_.focus();
}
/** @return {!HTMLElement} */
getDragImage() {
return this.dragImageEl_;
}
/** @return {!HTMLElement} */
getDragImageCenter() {
// dragImageEl_ has padding, so the drag image should be centered relative
// to tabEl_, the element within the padding.
return this.tabEl_;
}
/**
* @param {string} imgData
*/
updateThumbnail(imgData) {
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.embedderApi_.closeContainer();
}
/**
* @param {!Event} event
* @private
*/
onContextMenu_(event) {
event.preventDefault();
event.stopPropagation();
}
/**
* @param {!Event} event
* @private
*/
onClose_(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);
}
/**
* @param {!KeyboardEvent} event
* @private
*/
onKeyDown_(event) {
if (event.key === 'Enter' || event.key === ' ') {
this.onClick_();
}
}
/**
* @param {!PointerEvent} event
* @private
*/
onPointerUp_(event) {
event.stopPropagation();
if (event.pointerType !== 'touch' && event.button === 2) {
this.embedderApi_.showTabContextMenu(
this.tab.id, event.clientX, event.clientY);
}
}
resetSwipe() {
this.tabSwiper_.reset();
}
/**
* @param {boolean} isDragging
*/
setDragging(isDragging) {
this.toggleAttribute('dragging_', isDragging);
}
/** @param {boolean} isDraggedOut */
setDraggedOut(isDraggedOut) {
this.toggleAttribute('dragged-out_', isDraggedOut);
}
/** @return {boolean} */
isDraggedOut() {
return this.hasAttribute('dragged-out_');
}
/** @param {boolean} isTouchPressed */
setTouchPressed(isTouchPressed) {
this.toggleAttribute('touch_pressed_', isTouchPressed);
}
/**
* @return {!Promise}
*/
slideIn() {
const paddingInlineEnd = getPaddingInlineEndProperty();
// If this TabElement is the last tab, there needs to be enough space for
// the view to scroll to it. Therefore, immediately take up all the space
// it needs to and only animate the scale.
const isLastChild = this.nextElementSibling === null;
const startState = {
maxWidth: isLastChild ? 'var(--tabstrip-tab-width)' : 0,
transform: `scale(0)`,
};
startState[paddingInlineEnd] =
isLastChild ? 'var(--tabstrip-tab-spacing)' : 0;
const finishState = {
maxWidth: `var(--tabstrip-tab-width)`,
transform: `scale(1)`,
};
finishState[paddingInlineEnd] = 'var(--tabstrip-tab-spacing)';
return new Promise(resolve => {
const animation = this.animate([startState, finishState], {
duration: 300,
easing: 'cubic-bezier(.4, 0, 0, 1)',
});
animation.onfinish = () => {
resolve();
};
// TODO(crbug.com/1035678) By the next animation frame, the animation
// should start playing. By the time another animation frame happens,
// force play the animation if the animation has not yet begun. Remove
// if/when the Blink issue has been fixed.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (animation.pending) {
animation.play();
}
});
});
});
}
/**
* @return {!Promise}
*/
slideOut() {
if (!this.embedderApi_.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],
};
widthAnimationKeyframes[getPaddingInlineEndProperty()] =
['var(--tabstrip-tab-spacing)', 0];
const widthAnimation = this.animate(widthAnimationKeyframes, {
delay: 97.5,
duration: 300,
easing: 'cubic-bezier(.4, 0, 0, 1)',
fill: 'forwards',
});
const visibilityChangeListener = () => {
if (!this.embedderApi_.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();
};
});
}
}
customElements.define('tabstrip-tab', TabElement);
/**
* @param {!Element} element
* @return {boolean}
*/
export function isTabElement(element) {
return element.tagName === 'TABSTRIP-TAB';
}