Commit 73f43db1 authored by Roman Arora's avatar Roman Arora Committed by Josip Sokcevic

Tab Search WebUI: components and styling changes

Tab Search UI related changes:
- Custom tab-search-item component that represents each tab item
- Show only hostname of tab url on tab-search-item
- Close tab icon for certain list-item states
- Keyboard navigation for tab-search-items

Bug: 1099917
Change-Id: Idae91276a66b5b8e2efd7914266ea73107c58d34
Reviewed-on: https://chrome-internal-review.googlesource.com/c/chrome/browser/resources/tab_search/+/3154044Reviewed-by: default avatarDemetrios Papadopoulos <dpapad@chromium.org>
Reviewed-by: default avatarJohn Lee <johntlee@chromium.org>
Reviewed-by: default avatarTom Lukaszewicz <tluk@chromium.org>
Cr-Commit-Position: refs/heads/master@{#819570}
parent 4a2af52e
......@@ -16,6 +16,7 @@ js_type_check("closure_compile") {
js_library("app") {
deps = [
":tab_search_api_proxy",
":tab_search_item",
"//third_party/polymer/v3_0/components-chromium/polymer:polymer_bundled",
]
}
......@@ -27,7 +28,16 @@ js_library("tab_search_api_proxy") {
]
}
html_to_js("web_components") {
js_files = [ "app.js" ]
js_library("tab_search_item") {
deps = [
"//third_party/polymer/v3_0/components-chromium/polymer:polymer_bundled",
"//ui/webui/resources/cr_elements/cr_icon_button:cr_icon_button.m",
]
}
html_to_js("web_components") {
js_files = [
"app.js",
"tab_search_item.js",
]
}
<style>
::-webkit-scrollbar-thumb {
background: var(--google-grey-refresh-100);
}
::-webkit-scrollbar-thumb:hover {
background: var(--cr-focused-item-color);
}
::-webkit-scrollbar {
width: 4px;
}
#tabs {
max-height: 280px;
overflow-x: hidden;
overflow-y: auto;
}
</style>
<div id="searchBar">
<iron-icon id="searchIcon" icon="cr:search"></iron-icon>
<input id="searchInput" type="text" value="[[searchText_]]"
on-input="onSearchInput_">
on-input="onSearchInput_" on-keydown="onKeyDown_" autofocus>
</div>
<div id="content">
<template is="dom-repeat" items="[[filteredOpenTabs_]]">
<div class="row" id="[[item.tabId]]" on-click="onItemClick_">
<div>
<img src="[[item.favIconUrl]]">
</div>
<div>
<div>[[item.title]]</div>
<div>[[item.url]]</div>
</div>
</template>
<div id="tabs">
<iron-selector id="selector" selected="{{selectedIndex_}}" selected-class="selected">
<template id="tabs-list" is="dom-repeat" items="[[filteredOpenTabs_]]">
<tab-search-item id="[[item.tabId]]" data="[[item]]"
on-click="onItemClick_" tabindex="-1" >
</tab-search-item>
</template>
</iron-selector>
</div>
......@@ -3,7 +3,13 @@
// found in the LICENSE file.
import 'chrome://resources/cr_elements/icons.m.js';
import 'chrome://resources/cr_elements/shared_vars_css.m.js';
import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.js';
import 'chrome://resources/polymer/v3_0/iron-selector/iron-selector.js';
import './tab_search_item.js';
import {assert} from 'chrome://resources/js/assert.m.js';
import {html, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {TabSearchApiProxy, TabSearchApiProxyImpl} from './tab_search_api_proxy.js';
......@@ -34,6 +40,12 @@ export class TabSearchAppElement extends PolymerElement {
value: '',
},
/**
* The seleted item's index, or -1 if no item selected.
* @private {number}
*/
selectedIndex_: {type: Number, value: -1},
/** @private {?Array<!tabSearch.mojom.WindowTabs>} */
openTabs_: Array,
......@@ -47,6 +59,7 @@ export class TabSearchAppElement extends PolymerElement {
constructor() {
super();
/** @private {!TabSearchApiProxy} */
this.apiProxy_ = TabSearchApiProxyImpl.getInstance();
}
......@@ -62,12 +75,27 @@ export class TabSearchAppElement extends PolymerElement {
});
}
/** @return {number} */
getSelectedIndex() {
return this.selectedIndex_;
}
/**
* @param {!Event} e
* @param {?Array<!tabSearch.mojom.WindowTabs>} windows
* @param {string} searchText
* @return {!Array<!tabSearch.mojom.Tab>}
* @private
*/
onSearchInput_(e) {
this.searchText_ = e.target.value;
getFilteredTabs_(windows, searchText) {
const result = [];
if (windows) {
windows.forEach(window => {
result.push(...window.tabs.filter(filterFunc.bind(null, searchText)));
});
}
this.selectedIndex_ = result.length > 0 ? 0 : -1;
return result;
}
/**
......@@ -75,24 +103,79 @@ export class TabSearchAppElement extends PolymerElement {
* @private
*/
onItemClick_(e) {
const tabId = parseInt(e.currentTarget.id, 10);
const tabId = Number.parseInt(e.currentTarget.id, 10);
this.apiProxy_.switchToTab({tabId});
}
/**
* @param {?Array<!tabSearch.mojom.WindowTabs>} windows
* @param {string} searchText
* @return {!Array<!tabSearch.mojom.Tab>}
* @param {number} index A valid index for an element present in the
* filteredOpenTabs_ array.
* @return {?HTMLElement}
* @private
*/
getFilteredTabs_(windows, searchText) {
const result = [];
if (windows) {
windows.forEach(window => {
result.push(...window.tabs.filter(filterFunc.bind(null, searchText)));
});
getTabSearchItem_(index) {
const tabItemId = assert(this.filteredOpenTabs_[index]).tabId;
return this.shadowRoot.getElementById(tabItemId.toString());
}
/**
* @param {!KeyboardEvent} e
* @private
*/
onKeyDown_(e) {
if (this.selectedIndex_ !== -1) {
switch (e.key) {
case 'ArrowUp':
this.selectItem_(-1);
break;
case 'ArrowDown':
this.selectItem_(1);
break;
case 'Home':
this.selectItem_(-this.selectedIndex_);
break;
case 'End':
this.selectItem_(
this.filteredOpenTabs_.length - 1 - this.selectedIndex_);
break;
case 'Enter':
const selectedItem = this.filteredOpenTabs_[this.selectedIndex_];
this.apiProxy_.switchToTab({tabId: selectedItem.tabId});
break;
}
}
return result;
e.stopPropagation();
}
/**
* @param {!Event} e
* @private
*/
onSearchInput_(e) {
this.searchText_ = e.target.value;
}
/**
* @param {number} offset Distance from the desired item to select and the
* currently selected item.
* @private
*/
selectItem_(offset) {
const length = assert(this.filteredOpenTabs_.length);
this.selectedIndex_ = (this.selectedIndex_ + offset + length) % length;
// Ensure the scroll view can fully display a preceding or following tab
// item if existing. Use Math.sign to identify any such preceding or
// following item.
const scrollToIndex =
(this.selectedIndex_ === 0 ||
this.selectedIndex_ === this.filteredOpenTabs_.length - 1) ?
this.selectedIndex_ :
this.selectedIndex_ + Math.sign(offset);
this.getTabSearchItem_(scrollToIndex)
.scrollIntoView({behavior: 'smooth', block: 'nearest'});
}
/**
......
<style>
/* TODO(crbug.com/1110109): Add dark mode support. */
:host {
--horizontal-margin: 16px;
--icon-size: 16px;
--vertical-margin: 12px;
align-items: center;
background-color: white;
display: flex;
padding: var(--vertical-margin) var(--horizontal-margin);
}
:host-context(.selected, :host:hover) {
background-color: var(--cr-focused-item-color);
}
:host-context(.selected, :host:hover) .button-container {
display: flex;
}
.button-container {
display: none;
}
img {
height: var(--icon-size);
margin-inline-end: var(--horizontal-margin);
width: var(--icon-size);
}
.text-container {
flex-grow: 1;
overflow: hidden;
}
.primary-text,
.secondary-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.primary-text {
color: var(--cr-primary-text-color);
font-size: 13px;
margin-bottom: 3px;
}
.secondary-text {
color: var(--cr-secondary-text-color);
font-size: 12px;
}
cr-icon-button {
--cr-icon-button-margin-end: calc(var(--icon-size) / 4);
--cr-icon-button-margin-start: calc(var(--icon-size) / 4);
--cr-icon-button-size: var(--icon-size);
}
</style>
<img src="[[data.favIconUrl]]">
<div class="text-container">
<div class="primary-text">[[data.title]]</div>
<div class="secondary-text">[[urlHostname_(data.url)]]</div>
</div>
<div class="button-container">
<cr-icon-button aria-labelledby="label" iron-icon="cr:cancel"
on-click="onItemCancel_" title="$i18n{close}">
</cr-icon-button>
</div>
// Copyright 2020 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 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.m.js';
import 'chrome://resources/cr_elements/cr_icons_css.m.js';
import 'chrome://resources/cr_elements/icons.m.js';
import 'chrome://resources/cr_elements/shared_vars_css.m.js';
import {html, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
export class TabSearchItem extends PolymerElement {
static get is() {
return 'tab-search-item';
}
static get template() {
return html`{__html_template__}`;
}
static get properties() {
return {
/** @type {!tabSearch.mojom.Tab} */
data: Object,
};
}
/**
* @param {!Event} e
* @private
*/
onItemCancel_(e) {
this.dispatchEvent(new CustomEvent('close'));
e.stopPropagation();
}
/**
* @param {string} url
* @return {string}
* @private
*/
urlHostname_(url) {
return new URL(url).hostname;
}
}
customElements.define(TabSearchItem.is, TabSearchItem);
......@@ -3,6 +3,18 @@
<head>
<meta charset="utf-8">
<title>Tab Search</title>
<link rel="stylesheet" href="chrome://resources/css/text_defaults_md.css">
<style>
html,
body {
height: 100%;
width: 320px;
}
body {
margin: 0;
}
</style>
</head>
<body>
<tab-search-app></tab-search-app>
......
......@@ -22,10 +22,14 @@
<include name="IDR_TAB_SEARCH_PAGE_HTML"
file="tab_search_page.html"
type="BINDATA" />
<include name="IDR_TAB_SEARCH_ITEM"
file="${root_gen_dir}/chrome/browser/resources/tab_search/tab_search_item.js"
type="BINDATA"
use_base_dir="false"/>
<include name="IDR_TAB_SEARCH_MOJO_LITE_JS"
file="${root_gen_dir}/chrome/browser/ui/webui/tab_search/tab_search.mojom-lite.js"
use_base_dir="false"
type="BINDATA" />
type="BINDATA"
use_base_dir="false"/>
</includes>
</release>
</grit>
......@@ -2,10 +2,11 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {keyDownOn} from 'chrome://resources/polymer/v3_0/iron-test-helpers/mock-interactions.js';
import {TabSearchAppElement} from 'chrome://tab-search/app.js';
import {TabSearchApiProxy, TabSearchApiProxyImpl} from 'chrome://tab-search/tab_search_api_proxy.js'
import {assertEquals} from '../../chai_assert.js';
import {assertEquals, assertTrue} from '../../chai_assert.js';
import {flushTasks} from '../../test_util.m.js';
import {TestTabSearchApiProxy} from './test_tab_search_api_proxy.js';
......@@ -31,21 +32,37 @@ suite('TabSearchAppTest', () => {
* @return {!NodeList<!Element>}
*/
function queryRows() {
return tabSearchApp.shadowRoot.querySelectorAll('.row');
return tabSearchApp.shadowRoot.querySelectorAll('tab-search-item');
}
function setupData() {
function sampleData() {
const profileTabs = {
windows: [
{
active: true,
tabs: [{
index: 0,
tabId: 1,
favIconUrl: '',
title: 'Google',
url: 'https://www.google.com',
}],
tabs: [
{
index: 0,
tabId: 1,
favIconUrl: '',
title: 'Google',
url: 'https://www.google.com',
},
{
index: 1,
tabId: 5,
favIconUrl: '',
title: 'Amazon',
url: 'https://www.amazon.com',
},
{
index: 2,
tabId: 6,
favIconUrl: '',
title: 'Apple',
url: 'https://www.apple.com',
}
],
},
{
active: false,
......@@ -76,27 +93,84 @@ suite('TabSearchAppTest', () => {
]
};
testProxy.setProfileTabs(profileTabs);
return profileTabs;
}
setup(() => {
async function setupTest(sampleData) {
testProxy = new TestTabSearchApiProxy();
testProxy.setProfileTabs(sampleData);
TabSearchApiProxyImpl.instance_ = testProxy;
setupData();
tabSearchApp = /** @type {!TabSearchAppElement} */
(document.createElement('tab-search-app'));
document.body.innerHTML = '';
document.body.appendChild(tabSearchApp);
});
await flushTasks();
}
test('return all tabs', async () => {
await flushTasks();
verifyTabIds(queryRows(), [1, 2, 3, 4]);
await setupTest(sampleData());
verifyTabIds(queryRows(), [1, 5, 6, 2, 3, 4]);
});
test('return filtered tabs', async () => {
await setupTest(sampleData());
tabSearchApp.setSearchText('bing');
await flushTasks();
verifyTabIds(queryRows(), [2]);
});
test('Default tab selection when data is present', async () => {
await setupTest(sampleData());
assertTrue(
tabSearchApp.getSelectedIndex() != -1,
'No default selection in the precense of data');
});
test('Keyboard navigation on an empty list', async () => {
await setupTest({windows: [{active: true, tabs: []}]});
const searchInput = /** @type {!HTMLInputElement} */ (
tabSearchApp.shadowRoot.getElementById('searchInput'));
keyDownOn(searchInput, 0, [], 'ArrowUp');
assertEquals(-1, tabSearchApp.getSelectedIndex());
keyDownOn(searchInput, 0, [], 'ArrowDown');
assertEquals(-1, tabSearchApp.getSelectedIndex());
keyDownOn(searchInput, 0, [], 'Home');
assertEquals(-1, tabSearchApp.getSelectedIndex());
keyDownOn(searchInput, 0, [], 'End');
assertEquals(-1, tabSearchApp.getSelectedIndex());
});
test('Keyboard navigation abides by item list range boundaries', async () => {
await setupTest(sampleData());
const numTabs =
sampleData().windows.reduce((total, w) => total + w.tabs.length, 0);
const searchInput = /** @type {!HTMLInputElement} */ (
tabSearchApp.shadowRoot.getElementById('searchInput'));
keyDownOn(searchInput, 0, [], 'ArrowUp');
assertEquals(numTabs - 1, tabSearchApp.getSelectedIndex());
keyDownOn(searchInput, 0, [], 'ArrowDown');
assertEquals(0, tabSearchApp.getSelectedIndex());
keyDownOn(searchInput, 0, [], 'ArrowDown');
assertEquals(1, tabSearchApp.getSelectedIndex());
keyDownOn(searchInput, 0, [], 'ArrowUp');
assertEquals(0, tabSearchApp.getSelectedIndex());
keyDownOn(searchInput, 0, [], 'End');
assertEquals(numTabs - 1, tabSearchApp.getSelectedIndex());
keyDownOn(searchInput, 0, [], 'Home');
assertEquals(0, tabSearchApp.getSelectedIndex());
});
});
......@@ -25,7 +25,7 @@ export class TestTabSearchApiProxy extends TestBrowserProxy {
/** override */
switchToTab(tabInfo) {
this.methodCalled('swtichToTab');
this.methodCalled('switchToTab');
}
/** @param {tabSearch.mojom.ProfileTabs} profileTabs */
......
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