Commit 6b1d9571 authored by John Lee's avatar John Lee Committed by Commit Bot

WebUI Tab Strip: Add ability to close tabs using swipe gesture

Swiping a tab (in either direction, up or down) initially begins by
animating opacity from 0 to 1, and then animates the max-width to
0 to hide the space the TabElement takes in the tab strip. The tab
will only close if the user lets go of the pointer past this
max-width threshold or if the user swipes quick enough.

Bug: 1014679
Change-Id: I060876c1c2916cb69e964f39c18f72a4d379931f
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/1888380
Commit-Queue: John Lee <johntlee@chromium.org>
Reviewed-by: default avatarDemetrios Papadopoulos <dpapad@chromium.org>
Cr-Commit-Position: refs/heads/master@{#712360}
parent 3aee3ff5
......@@ -14,6 +14,7 @@ js_type_check("closure_compile") {
":tab_list",
":tab_strip_embedder_proxy",
":tab_strip_options",
":tab_swiper",
":tabs_api_proxy",
]
}
......@@ -53,6 +54,9 @@ js_library("tab_strip_embedder_proxy") {
js_library("tab_strip_options") {
}
js_library("tab_swiper") {
}
group("tab_strip_modules") {
deps = [
":alert_indicator_module",
......
......@@ -2,7 +2,6 @@
:host {
--tabstrip-tab-transition-duration: 250ms;
cursor: pointer;
height: var(--tabstrip-tab-height);
position: relative;
width: var(--tabstrip-tab-width);
......@@ -15,9 +14,9 @@
color: var(--tabstrip-tab-text-color);
display: flex;
flex-direction: column;
height: 100%;
height: var(--tabstrip-tab-height);
overflow: hidden;
width: 100%;
width: var(--tabstrip-tab-width);
}
:host([active]) #tab {
......@@ -228,8 +227,8 @@
/* Pinned tab styles */
:host([pinned]) {
height: var(--tabstrip-pinned-tab-size);
width: var(--tabstrip-pinned-tab-size);
--tabstrip-tab-height: var(--tabstrip-pinned-tab-size);
--tabstrip-tab-width: var(--tabstrip-pinned-tab-size);
}
:host([pinned]) #title {
......
......@@ -12,9 +12,10 @@ import {AlertIndicatorsElement} from './alert_indicators.js';
import {CustomElement} from './custom_element.js';
import {TabStripEmbedderProxy} from './tab_strip_embedder_proxy.js';
import {tabStripOptions} from './tab_strip_options.js';
import {TabSwiper} from './tab_swiper.js';
import {TabData, TabNetworkState, TabsApiProxy} from './tabs_api_proxy.js';
export const DEFAULT_ANIMATION_DURATION = 125;
const DEFAULT_ANIMATION_DURATION = 125;
/**
* @param {!TabData} tab
......@@ -85,9 +86,13 @@ export class TabElement extends CustomElement {
this.titleTextEl_ = /** @type {!HTMLElement} */ (
this.shadowRoot.querySelector('#titleText'));
this.tabEl_.addEventListener('click', this.onClick_.bind(this));
this.tabEl_.addEventListener('contextmenu', this.onContextMenu_.bind(this));
this.closeButtonEl_.addEventListener('click', this.onClose_.bind(this));
this.tabEl_.addEventListener('click', () => this.onClick_());
this.tabEl_.addEventListener('contextmenu', e => this.onContextMenu_(e));
this.closeButtonEl_.addEventListener('click', e => this.onClose_(e));
this.addEventListener('swipe', () => this.onSwipe_());
/** @private @const {!TabSwiper} */
this.tabSwiper_ = new TabSwiper(this);
}
/** @return {!TabData} */
......@@ -137,6 +142,12 @@ export class TabElement extends CustomElement {
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);
}
......@@ -154,7 +165,7 @@ export class TabElement extends CustomElement {
/** @private */
onClick_() {
if (!this.tab_) {
if (!this.tab_ || this.tabSwiper_.wasSwiping()) {
return;
}
......@@ -193,6 +204,13 @@ export class TabElement extends CustomElement {
this.tabsApi_.closeTab(this.tab_.id);
}
/** @private */
onSwipe_() {
// Prevent slideOut animation from playing.
this.remove();
this.tabsApi_.closeTab(this.tab_.id);
}
/**
* @param {boolean} dragging
*/
......@@ -214,7 +232,12 @@ export class TabElement extends CustomElement {
duration: DEFAULT_ANIMATION_DURATION,
fill: 'forwards',
});
animation.onfinish = resolve;
animation.onfinish = () => {
// Cancel the effects of the animation so that maxWidth and opacity
// can be animated by other animations.
animation.cancel();
resolve();
};
});
}
......
......@@ -22,6 +22,7 @@
background: var(--tabstrip-background-color);
margin: 0;
padding: 0;
touch-action: pan-x;
}
::-webkit-scrollbar {
......
......@@ -61,6 +61,11 @@
file="tab_strip_options.js"
type="chrome_html"
compress="gzip"/>
<structure
name="IDR_TAB_STRIP_TAB_SWIPER_JS"
file="tab_swiper.js"
type="chrome_html"
compress="gzip"/>
</structures>
<includes>
......
// 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.
/**
* The minimum amount of pixels needed for the user to swipe for the opacity
* to start animating to 0.
* @const {number}
*/
export const OPACITY_ANIMATION_THRESHOLD_PX = 30;
/**
* The minimum amount of pixels needed for the user to swipe to actually close
* the tab. This also triggers animating other properties to suggest more that
* the tab will close, such as animating the max-width.
* @const {number}
*/
export const SWIPE_START_THRESHOLD_PX = 100;
/**
* The maximum amount of pixels needed to swipe a tab away. This is how many
* pixels across the screen the user needs to swipe for the swipe away
* animation to complete such that the tab is gone from the screen.
* @const {number}
*/
export const SWIPE_FINISH_THRESHOLD_PX = 200;
/**
* The pixel that maps to the time of the swipe away animation at which the tab
* is in its original stable position.
* @const {number}
*/
const SWIPE_ANIMATION_BASELINE_PX = SWIPE_FINISH_THRESHOLD_PX;
/**
* The swipe away animation is bidirectional to allow the user to swipe in
* either direction, so the total span of pixels is SWIPE_FINISH_THRESHOLD_PX in
* both directions.
* @const {number}
*/
const SWIPE_ANIMATION_TOTAL_PX = SWIPE_FINISH_THRESHOLD_PX * 2;
/**
* The minimum velocity of pixels per milliseconds required for the tab to
* register the set of pointer events as an intended swipe.
* @const {number}
*/
const SWIPE_VELOCITY_THRESHOLD = 0.1;
export class TabSwiper {
/** @param {!HTMLElement} element */
constructor(element) {
/** @private @const {!HTMLElement} */
this.element_ = element;
/** @private @const {!Animation} */
this.animation_ = this.createAnimation_();
/**
* Whether any part of the animation that updates properties has begun since
* the last pointerdown event.
* @private {boolean}
*/
this.animationInitiated_ = false;
/** @private {?PointerEvent} */
this.currentPointerDownEvent_ = null;
/** @private @const {!Function} */
this.pointerDownListener_ = e =>
this.onPointerDown_(/** @type {!PointerEvent} */ (e));
/** @private @const {!Function} */
this.pointerMoveListener_ = e =>
this.onPointerMove_(/** @type {!PointerEvent} */ (e));
/** @private @const {!Function} */
this.pointerLeaveListener_ = e =>
this.onPointerLeave_(/** @type {!PointerEvent} */ (e));
/** @private @const {!Function} */
this.pointerUpListener_ = e =>
this.onPointerUp_(/** @type {!PointerEvent} */ (e));
}
/** @private */
clearPointerEvents_() {
this.currentPointerDownEvent_ = null;
this.element_.removeEventListener(
'pointerleave', this.pointerLeaveListener_);
this.element_.removeEventListener('pointermove', this.pointerMoveListener_);
this.element_.removeEventListener('pointerup', this.pointerUpListener_);
}
/** @private */
createAnimation_() {
const animation = new Animation(new KeyframeEffect(
this.element_,
[
{
// Fully swiped up.
maxWidth: '0px',
opacity: 0,
transform: `translateY(-${SWIPE_FINISH_THRESHOLD_PX}px)`
},
{
// Start of max-width animation swiping up.
maxWidth: 'var(--tabstrip-tab-width)',
offset: (SWIPE_ANIMATION_BASELINE_PX - SWIPE_START_THRESHOLD_PX) /
SWIPE_ANIMATION_TOTAL_PX,
},
{
// Start of opacity animation swiping up.
maxWidth: 'var(--tabstrip-tab-width)',
offset:
(SWIPE_ANIMATION_BASELINE_PX - OPACITY_ANIMATION_THRESHOLD_PX) /
SWIPE_ANIMATION_TOTAL_PX,
opacity: 1,
transform: `translateY(0)`
},
{
// Base.
opacity: 1,
maxWidth: 'var(--tabstrip-tab-width)',
transform: `translateY(0)`
},
{
// Start of opacity animation swiping down.
maxWidth: 'var(--tabstrip-tab-width)',
offset:
(SWIPE_ANIMATION_BASELINE_PX + OPACITY_ANIMATION_THRESHOLD_PX) /
SWIPE_ANIMATION_TOTAL_PX,
opacity: 1,
transform: `translateY(0)`
},
{
// Start of opacity animation swiping down.
maxWidth: 'var(--tabstrip-tab-width)',
offset: (SWIPE_ANIMATION_BASELINE_PX + SWIPE_START_THRESHOLD_PX) /
SWIPE_ANIMATION_TOTAL_PX,
},
{
// Fully swiped down.
maxWidth: '0px',
opacity: 0,
transform: `translateY(${SWIPE_FINISH_THRESHOLD_PX}px)`
},
],
{
duration: SWIPE_ANIMATION_TOTAL_PX,
fill: 'both',
}));
animation.currentTime = SWIPE_FINISH_THRESHOLD_PX;
animation.onfinish = () => {
this.element_.dispatchEvent(new CustomEvent('swipe'));
};
return animation;
}
/**
* @param {!PointerEvent} event
* @private
*/
onPointerDown_(event) {
if (this.currentPointerDownEvent_) {
return;
}
this.animationInitiated_ = false;
this.currentPointerDownEvent_ = event;
this.element_.addEventListener('pointerleave', this.pointerLeaveListener_);
this.element_.addEventListener('pointermove', this.pointerMoveListener_);
this.element_.addEventListener('pointerup', this.pointerUpListener_);
}
/**
* @param {!PointerEvent} event
* @private
*/
onPointerLeave_(event) {
if (this.currentPointerDownEvent_.pointerId !== event.pointerId) {
return;
}
this.clearPointerEvents_();
}
/**
* @param {!PointerEvent} event
* @private
*/
onPointerMove_(event) {
if (this.currentPointerDownEvent_.pointerId !== event.pointerId ||
event.movementY === 0) {
return;
}
const yDiff = event.clientY - this.currentPointerDownEvent_.clientY;
const animationTime = SWIPE_ANIMATION_BASELINE_PX + yDiff;
this.animation_.currentTime =
Math.max(0, Math.min(SWIPE_ANIMATION_TOTAL_PX, animationTime));
if (!this.animationInitiated_ &&
Math.abs(yDiff) > OPACITY_ANIMATION_THRESHOLD_PX) {
this.animationInitiated_ = true;
this.element_.setPointerCapture(event.pointerId);
}
}
/**
* @param {!PointerEvent} event
* @private
*/
onPointerUp_(event) {
if (this.currentPointerDownEvent_.pointerId !== event.pointerId) {
return;
}
const pixelsSwiped =
this.animation_.currentTime - SWIPE_ANIMATION_BASELINE_PX;
const swipedEnoughToClose =
Math.abs(pixelsSwiped) > SWIPE_START_THRESHOLD_PX;
const wasHighVelocity =
Math.abs(
pixelsSwiped /
(event.timeStamp - this.currentPointerDownEvent_.timeStamp)) >
SWIPE_VELOCITY_THRESHOLD;
if (pixelsSwiped === SWIPE_FINISH_THRESHOLD_PX) {
// The user has swiped the max amount of pixels to swipe and the animation
// has already completed all its keyframes, so just fire the onfinish
// events on the animation.
this.animation_.finish();
} else if (swipedEnoughToClose || wasHighVelocity) {
this.animation_.playbackRate = Math.sign(pixelsSwiped);
this.animation_.play();
} else {
this.animation_.cancel();
this.animation_.currentTime = SWIPE_FINISH_THRESHOLD_PX;
}
this.clearPointerEvents_();
}
startObserving() {
this.element_.addEventListener('pointerdown', this.pointerDownListener_);
}
stopObserving() {
this.element_.removeEventListener('pointerdown', this.pointerDownListener_);
}
/** @return {boolean} */
wasSwiping() {
return this.animationInitiated_;
}
}
......@@ -69,3 +69,13 @@ var TabStripAlertIndicatorTest = class extends TabStripBrowserTest {
TEST_F('TabStripAlertIndicatorTest', 'All', function() {
mocha.run();
});
var TabStripTabSwiperTest = class extends TabStripBrowserTest {
get browsePreload() {
return 'chrome://tab-strip/test_loader.html?module=tab_strip/tab_swiper_test.js';
}
};
TEST_F('TabStripTabSwiperTest', 'All', function() {
mocha.run();
});
// 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 {OPACITY_ANIMATION_THRESHOLD_PX, SWIPE_FINISH_THRESHOLD_PX, SWIPE_START_THRESHOLD_PX, TabSwiper} from 'chrome://tab-strip/tab_swiper.js';
import {eventToPromise} from '../test_util.m.js';
import {TestTabsApiProxy} from './test_tabs_api_proxy.js';
suite('TabSwiper', () => {
let tabElement;
let tabSwiper;
const tab = {id: 1001};
setup(() => {
document.body.innerHTML = '';
tabElement = document.createElement('div');
tabElement.tab = tab;
document.body.appendChild(tabElement);
tabSwiper = new TabSwiper(tabElement);
tabSwiper.startObserving();
});
test('swiping progresses the animation', () => {
document.body.style.setProperty('--tabstrip-tab-width', '100px');
const tabElStyle = window.getComputedStyle(tabElement);
const startY = 50;
const pointerState = {clientY: startY, pointerId: 1};
tabElement.dispatchEvent(new PointerEvent('pointerdown', pointerState));
function testSwipeAnimation(swipeUp) {
const direction = swipeUp ? -1 : 1;
// Swipe was not enough to start any part of the animation.
pointerState.clientY = startY + (direction * 1);
pointerState.movementY = 1; /* Any non-0 value here is fine. */
tabElement.dispatchEvent(new PointerEvent('pointermove', pointerState));
assertEquals(tabElStyle.maxWidth, '100px');
assertEquals(tabElStyle.opacity, '1');
// Swipe was enough to start animating opacity.
pointerState.clientY =
startY + (direction * (OPACITY_ANIMATION_THRESHOLD_PX + 1));
tabElement.dispatchEvent(new PointerEvent('pointermove', pointerState));
assertEquals(tabElStyle.maxWidth, '100px');
assertTrue(
parseFloat(tabElStyle.opacity) > 0 &&
parseFloat(tabElStyle.opacity) < 1);
// Swipe was enough to start animating max width.
pointerState.clientY =
startY + (direction * (SWIPE_START_THRESHOLD_PX + 1));
tabElement.dispatchEvent(new PointerEvent('pointermove', pointerState));
assertTrue(
parseInt(tabElStyle.maxWidth) > 0 &&
parseInt(tabElStyle.maxWidth) < 100);
assertTrue(
parseFloat(tabElStyle.opacity) > 0 &&
parseFloat(tabElStyle.opacity) < 1);
// Swipe was enough to finish animating.
pointerState.clientY =
startY + (direction * (SWIPE_FINISH_THRESHOLD_PX + 1));
tabElement.dispatchEvent(new PointerEvent('pointermove', pointerState));
assertEquals(tabElStyle.maxWidth, '0px');
assertEquals(tabElStyle.opacity, '0');
}
testSwipeAnimation(true);
testSwipeAnimation(false);
});
test('finishing the swipe animation fires an event', async () => {
async function testFiredEvent(swipeUp) {
const firedEventPromise = eventToPromise('swipe', tabElement);
const startY = 50;
const direction = swipeUp ? -1 : 1;
const pointerState = {clientY: startY, pointerId: 1};
tabElement.dispatchEvent(new PointerEvent('pointerdown', pointerState));
pointerState.clientY =
startY + (direction * (SWIPE_FINISH_THRESHOLD_PX + 1));
pointerState.movementY = 1; /* Any non-0 value here is fine. */
tabElement.dispatchEvent(new PointerEvent('pointermove', pointerState));
tabElement.dispatchEvent(new PointerEvent('pointerup', pointerState));
await firedEventPromise;
}
await testFiredEvent(true);
// Re-add the element back to the DOM and re-test for swiping down.
document.body.appendChild(tabElement);
await testFiredEvent(false);
});
test('swiping enough and releasing finishes the animation', async () => {
async function testReleasing(swipeUp) {
const firedEventPromise = eventToPromise('swipe', tabElement);
const tabElStyle = window.getComputedStyle(tabElement);
const startY = 50;
const direction = swipeUp ? -1 : 1;
const pointerState = {clientY: 50, pointerId: 1};
tabElement.dispatchEvent(new PointerEvent('pointerdown', pointerState));
pointerState.clientY =
startY + (direction * (SWIPE_START_THRESHOLD_PX + 1));
pointerState.movementY = 1; /* Any non-0 value here is fine. */
tabElement.dispatchEvent(new PointerEvent('pointermove', pointerState));
tabElement.dispatchEvent(new PointerEvent('pointerup', pointerState));
await firedEventPromise;
assertEquals(tabElStyle.maxWidth, '0px');
assertEquals(tabElStyle.opacity, '0');
}
await testReleasing(true);
// Re-add the element back to the DOM and re-test for swiping down.
document.body.appendChild(tabElement);
await testReleasing(false);
});
test('swiping and letting go before resets animation', () => {
tabElement.style.setProperty('--tabstrip-tab-width', '100px');
function testReleasing(swipeUp) {
const tabElStyle = window.getComputedStyle(tabElement);
const startY = 50;
const direction = swipeUp ? -1 : 1;
const pointerState = {clientY: 50, pointerId: 1};
tabElement.dispatchEvent(new PointerEvent('pointerdown', pointerState));
pointerState.clientY = startY + (direction * 1);
pointerState.movementY = 1; /* Any non-0 value here is fine. */
tabElement.dispatchEvent(new PointerEvent('pointermove', pointerState));
tabElement.dispatchEvent(new PointerEvent('pointerup', pointerState));
assertEquals(tabElStyle.maxWidth, '100px');
assertEquals(tabElStyle.opacity, '1');
}
testReleasing(true);
testReleasing(false);
});
test('swiping fast enough finishes playing the animation', async () => {
const tabElStyle = window.getComputedStyle(tabElement);
async function testHighSpeedSwipe(swipeUp) {
const firedEventPromise = eventToPromise('swipe', tabElement);
const direction = swipeUp ? -1 : 1;
const startY = 50;
const pointerState = {clientY: 50, pointerId: 1};
tabElement.dispatchEvent(new PointerEvent('pointerdown', pointerState));
pointerState.clientY = 100;
pointerState.movementY = direction * 100;
pointerState.timestamp = 1020;
tabElement.dispatchEvent(new PointerEvent('pointermove', pointerState));
tabElement.dispatchEvent(new PointerEvent('pointerup', pointerState));
await firedEventPromise;
assertEquals(tabElStyle.maxWidth, '0px');
assertEquals(tabElStyle.opacity, '0');
}
await testHighSpeedSwipe(true);
// Re-add the element back to the DOM and re-test for swiping down.
document.body.appendChild(tabElement);
await testHighSpeedSwipe(false);
});
});
......@@ -58,14 +58,14 @@ suite('Tab', function() {
await animationPromise;
// After animation completes
assertEquals('1', window.getComputedStyle(tabElement).opacity);
assertEquals('280px', window.getComputedStyle(tabElement).maxWidth);
assertEquals('none', window.getComputedStyle(tabElement).maxWidth);
});
test('slideOut animates out the element', async () => {
const animationPromise = tabElement.slideOut();
// Before animation completes
assertEquals('1', window.getComputedStyle(tabElement).opacity);
assertEquals('280px', window.getComputedStyle(tabElement).maxWidth);
assertEquals('none', window.getComputedStyle(tabElement).maxWidth);
await animationPromise;
// After animation completes
assertFalse(document.body.contains(tabElement));
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment