Commit 74dbe648 authored by Roman Arora's avatar Roman Arora Committed by Commit Bot

Tab Search: Infinite list component

A list component that renders DOM items on demand based on the viewable
scroll area and user navigation and scrolling interactions. Provides a
performance gain for use cases where users have several dozens of items
that should be available while scrolling the list by deferring scripting
and rendering costs until necessary to fill the current scroll view.

Bug: 1099917
Change-Id: Ib22c0edf5ebc5febe3d8d6d4f7d8ac319e9bd0a2
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2503401
Commit-Queue: Roman Arora <romanarora@google.com>
Reviewed-by: default avatarThomas Lukaszewicz <tluk@chromium.org>
Cr-Commit-Position: refs/heads/master@{#826287}
parent 57103c5e
......@@ -54,6 +54,7 @@ preprocess_grit("preprocess_web_components") {
out_manifest = "$target_gen_dir/$preprocess_web_components_manifest"
in_files = [
"app.js",
"infinite_list.js",
"tab_search_item.js",
"tab_search_search_field.js",
]
......@@ -103,6 +104,7 @@ js_type_check("closure_compile") {
deps = [
":app",
":fuzzy_search",
":infinite_list",
":tab_data",
":tab_search_api_proxy",
":tab_search_item",
......@@ -118,7 +120,6 @@ js_library("app") {
":tab_search_item",
":tab_search_search_field",
"//third_party/polymer/v3_0/components-chromium/iron-a11y-announcer",
"//third_party/polymer/v3_0/components-chromium/iron-selector:iron-selector",
"//third_party/polymer/v3_0/components-chromium/polymer:polymer_bundled",
"//ui/webui/resources/js:cr.m",
"//ui/webui/resources/js:load_time_data.m",
......@@ -134,6 +135,13 @@ js_library("fuzzy_search") {
]
}
js_library("infinite_list") {
deps = [
"//third_party/polymer/v3_0/components-chromium/iron-selector:iron-selector",
"//third_party/polymer/v3_0/components-chromium/polymer:polymer_bundled",
]
}
js_library("tab_data") {
deps = []
}
......@@ -166,6 +174,7 @@ js_library("tab_search_search_field") {
html_to_js("web_components") {
js_files = [
"app.js",
"infinite_list.js",
"tab_search_item.js",
"tab_search_search_field.js",
]
......
<style include="mwb-shared-style">
#tabs {
max-height: 280px;
overflow-x: hidden;
overflow-y: auto;
position: relative;
}
#tabsContainer {
height: calc(var(--mwb-item-height) * var(--item-count, 0));
#tabsList {
--list-max-height: 280px;
}
#no-results {
......@@ -62,20 +55,17 @@
label="$i18n{searchTabs}" on-focus="onSearchFocus_"
on-keydown="onSearchKeyDown_" on-search-changed="onSearchChanged_">
</tab-search-search-field>
<div id="tabs">
<div id="tabsContainer">
<iron-selector id="selector" on-keydown="onItemKeyDown_"
selected="{{selectedIndex_}}" selected-class="selected" role="listbox">
<template id="tabsList" is="dom-repeat" items="[[filteredOpenTabs_]]"
initial-count="[[chunkingItemCount_]]">
<tab-search-item id="[[item.tab.tabId]]" aria-label="[[ariaLabel_(item)]]"
class="mwb-list-item" data="[[item]]"
on-click="onItemClick_" on-close="onItemClose_"
on-focus="onItemFocus_" tabindex="0" role="option">
</tab-search-item>
</template>
</iron-selector>
</div>
<div hidden="[[!filteredOpenTabs_.length]]">
<infinite-list id="tabsList" items="[[filteredOpenTabs_]]" >
<template>
<tab-search-item id="[[item.tab.tabId]]" aria-label="[[ariaLabel_(item)]]"
class="mwb-list-item" data="[[item]]"
on-click="onItemClick_" on-close="onItemClose_"
on-focus="onItemFocus_" on-keydown="onItemKeyDown_" tabindex="0"
role="option">
</tab-search-item>
</template>
</infinite-list>
</div>
<div id="no-results" hidden="[[filteredOpenTabs_.length]]">
$i18n{noResultsFound}
......
......@@ -8,24 +8,22 @@ import 'chrome://resources/cr_elements/mwb_shared_style.js';
import 'chrome://resources/cr_elements/mwb_shared_vars.js';
import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.js';
import 'chrome://resources/polymer/v3_0/iron-iconset-svg/iron-iconset-svg.js';
import 'chrome://resources/polymer/v3_0/iron-selector/iron-selector.js';
import './infinite_list.js';
import './tab_search_item.js';
import './tab_search_search_field.js';
import './strings.js';
import {assert} from 'chrome://resources/js/assert.m.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.m.js';
import {listenOnce} from 'chrome://resources/js/util.m.js';
import {IronA11yAnnouncer} from 'chrome://resources/polymer/v3_0/iron-a11y-announcer/iron-a11y-announcer.js';
import {html, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {afterNextRender, html, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {fuzzySearch} from './fuzzy_search.js';
import {InfiniteList, NO_SELECTION, selectorNavigationKeys} from './infinite_list.js';
import {TabData} from './tab_data.js';
import {Tab, WindowTabs} from './tab_search.mojom-webui.js';
import {TabSearchApiProxy, TabSearchApiProxyImpl} from './tab_search_api_proxy.js';
const selectorNavigationKeys = ['ArrowUp', 'ArrowDown', 'Home', 'End'];
export class TabSearchAppElement extends PolymerElement {
static get is() {
return 'tab-search-app';
......@@ -55,16 +53,6 @@ export class TabSearchAppElement extends PolymerElement {
value: [],
},
/**
* Controls the number of tab search list items initially rendered in
* dom-repeat's chunked rendering mode.
* @private {number}
*/
chunkingItemCount_: {
type: Number,
value: 10,
},
/**
* Options for fuzzy search.
* @private {!Object}
......@@ -160,26 +148,13 @@ export class TabSearchAppElement extends PolymerElement {
// Prior to the first load |this.openTabs_| has not been set. Record the
// time it takes for the initial list of tabs to render.
if (!this.openTabs_) {
listenOnce(this.$.tabsList, 'rendered-item-count-changed', e => {
const event = /** @type {!CustomEvent<!{value: number}>} */ (e);
// The initial rendered tab list must be non-zero.
assert(event.detail.value > 0);
// Chunking is used to bound the time to interactive for users
// irrespective of the number of tabs they have open. This is no longer
// needed after the initial list render and can cause flickering on
// updates so disable it here.
// TODO(tluk): Investigate a more efficient way to handle this.
this.chunkingItemCount_ = 0;
// Push showUI() to the event loop to allow reflow to occur following
// the DOM update.
setTimeout(() => {
listenOnce(this.$.tabsList, 'dom-change', () => {
afterNextRender(this, () => {
this.apiProxy_.showUI();
chrome.metricsPrivate.recordTime(
'Tabs.TabSearch.WebUI.InitialTabsRenderTime',
Math.round(window.performance.now()));
}, 0);
'Tabs.TabSearch.WebUI.InitialTabsRenderTime',
Math.round(window.performance.now()));
});
});
}
this.openTabs_ = profileTabs.windows;
......@@ -228,10 +203,7 @@ export class TabSearchAppElement extends PolymerElement {
* @return {number}
*/
getSelectedIndex() {
const selector = /** @type {!IronSelectorElement} */ (this.$.selector);
return selector.selected !== undefined ?
/** @type {number} */ (selector.selected) :
-1;
return /** @type {!InfiniteList} */ (this.$.tabsList).selected;
}
/**
......@@ -243,9 +215,8 @@ export class TabSearchAppElement extends PolymerElement {
this.updateFilteredTabs_(this.openTabs_ || []);
// Reset the selected item whenever a search query is provided.
this.$.selector.selected =
this.filteredOpenTabs_.length > 0 ? 0 : undefined;
this.$.tabs.scrollTop = 0;
/** @type {!InfiniteList} */ (this.$.tabsList).selected =
this.filteredOpenTabs_.length > 0 ? 0 : NO_SELECTION;
const length = this.filteredOpenTabs_.length;
let text;
......@@ -267,7 +238,7 @@ export class TabSearchAppElement extends PolymerElement {
/** @private */
onFeedbackFocus_() {
this.$.selector.selected = undefined;
/** @type {!InfiniteList} */ (this.$.tabsList).selected = NO_SELECTION;
}
/**
......@@ -288,11 +259,27 @@ export class TabSearchAppElement extends PolymerElement {
const tabId = Number.parseInt(e.currentTarget.id, 10);
this.apiProxy_.closeTab(tabId);
this.announceA11y_(loadTimeData.getString('a11yTabClosed'));
listenOnce(this.$.tabsList, 'rendered-item-count-changed', () => {
listenOnce(this.$.tabsList, 'iron-items-changed', () => {
performance.mark('close_tab:benchmark_end');
});
}
/**
* @param {!Event} e
* @private
*/
onItemKeyDown_(e) {
if (e.key !== 'Enter' && e.key !== ' ') {
return;
}
e.stopPropagation();
e.preventDefault();
this.apiProxy_.switchToTab(
{tabId: this.getSelectedTab_().tabId}, !!this.searchText_);
}
/**
* @param {!Array<!WindowTabs>} newOpenTabs
* @private
......@@ -304,9 +291,10 @@ export class TabSearchAppElement extends PolymerElement {
// selected; else retain the currently selected index. If the list
// shrunk above the selected index, select the last index in the list.
// If there are no matching results, set the selected index value to none.
this.$.selector.selectIndex(Math.min(
const tabsList = /** @type {!InfiniteList} */ (this.$.tabsList);
tabsList.selected = Math.min(
Math.max(this.getSelectedIndex(), 0),
this.filteredOpenTabs_.length - 1));
this.filteredOpenTabs_.length - 1);
}
/**
......@@ -314,67 +302,18 @@ export class TabSearchAppElement extends PolymerElement {
* @private
*/
onItemFocus_(e) {
this.$.selector.selected =
// Ensure that when a TabSearchItem receives focus, it becomes the selected
// item in the list.
/** @type {!InfiniteList} */ (this.$.tabsList).selected =
e.currentTarget.parentNode.indexOf(e.currentTarget);
this.updateScrollView_();
}
/**
* @param {string} key Keyboard event key value.
* @private
*/
selectorNavigate_(key) {
const selector = /** @type {!IronSelectorElement} */ (this.$.selector);
switch (key) {
case 'ArrowUp':
selector.selectPrevious();
break;
case 'ArrowDown':
selector.selectNext();
break;
case 'Home':
selector.selected = 0;
break;
case 'End':
selector.selected = this.filteredOpenTabs_.length - 1;
break;
}
}
/**
* Handles key events when list item elements have focus.
* @param {!KeyboardEvent} e
* @private
*/
onItemKeyDown_(e) {
if (e.shiftKey) {
return;
}
if (this.getSelectedIndex() === -1) {
// No tabs matching the search text criteria.
return;
}
if (selectorNavigationKeys.includes(e.key)) {
this.selectorNavigate_(e.key);
/** @type {!HTMLElement} */ (this.$.selector.selectedItem).focus({
preventScroll: true
});
e.stopPropagation();
e.preventDefault();
} else if (e.key === 'Enter' || e.key === ' ') {
this.apiProxy_.switchToTab(
{tabId: this.getSelectedTab_().tabId}, !!this.searchText_);
e.stopPropagation();
}
}
/** @private */
onSearchFocus_() {
if (this.$.selector.selected === undefined &&
const tabsList = /** @type {!InfiniteList} */ (this.$.tabsList);
if (tabsList.selected === NO_SELECTION &&
this.filteredOpenTabs_.length > 0) {
this.$.selector.selectIndex(0);
tabsList.selected = 0;
}
}
......@@ -407,8 +346,8 @@ export class TabSearchAppElement extends PolymerElement {
}
if (selectorNavigationKeys.includes(e.key)) {
this.selectorNavigate_(e.key);
this.updateScrollView_();
/** @type {!InfiniteList} */ (this.$.tabsList).navigate(e.key);
e.stopPropagation();
e.preventDefault();
......@@ -459,42 +398,6 @@ export class TabSearchAppElement extends PolymerElement {
0);
this.filteredOpenTabs_ =
fuzzySearch(this.searchText_, result, this.fuzzySearchOptions_);
// Update the item count in css so that the css rule can calculate the final
// height of the tabsContainer. This prevents the scrolling height from
// changing as list items are added to the dom incrementally via chunking
// mode.
this.$.tabsContainer.style
.setProperty("--item-count", this.filteredOpenTabs_.length.toString());
}
/**
* Ensure the scroll view can fully display a preceding or following tab item
* if existing.
* @private
*/
updateScrollView_() {
const selectedIndex = this.getSelectedIndex();
if (selectedIndex === 0 ||
selectedIndex === this.filteredOpenTabs_.length - 1) {
/** @type {!Element} */ (this.$.selector.selectedItem).scrollIntoView({
behavior: 'smooth'
});
} else {
const previousItem = this.$.selector.items[this.$.selector.selected - 1];
if (previousItem.offsetTop < this.$.tabs.scrollTop) {
/** @type {!Element} */ (previousItem)
.scrollIntoView({behavior: 'smooth', block: 'nearest'});
return;
}
const nextItem = this.$.selector.items[this.$.selector.selected + 1];
if (nextItem.offsetTop + nextItem.offsetHeight >
this.$.tabs.scrollTop + this.$.tabs.offsetHeight) {
/** @type {!Element} */ (nextItem).scrollIntoView(
{behavior: 'smooth', block: 'nearest'});
}
}
}
/** return {!Tab} */
......
<style include="mwb-shared-style">
:host {
display: block;
}
#container {
max-height: var(--list-max-height);
overflow-x: hidden;
overflow-y: auto;
position: relative;
}
</style>
<div id="container" on-scroll="onScroll_">
<div id="items">
<iron-selector id="selector" on-keydown="onKeyDown_"
on-iron-items-changed="updateScrollerSize_"
on-iron-select="onSelectedChanged_" role="listbox"
selected-class="selected">
</iron-selector>
</div>
</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.
/**
* @fileoverview 'infinite-list' is a component optimized for showing a
* list of items that overflows the view and requires scrolling. For performance
* reasons, The DOM items are added incrementally to the view as the user
* scrolls through the list. The template inside this element represents the DOM
* to create for each list item. The `items` property specifies an array of list
* item data. The component leverages an <iron-selector> to manage item
* selection and styling and a <dom-repeat> which renders the provided template.
*
* Note that the component expects a '--list-max-height' variable to be defined
* in order to determine its maximum height. Additionally, it expects the
* `chunkItemCount` property to be a number of DOM items that is large enough to
* fill the view.
*/
/* TODO(crbug.com/1147535): Decouple MWB style from component */
import 'chrome://resources/cr_elements/mwb_shared_style.js';
import 'chrome://resources/cr_elements/mwb_shared_vars.js';
import 'chrome://resources/polymer/v3_0/iron-selector/iron-selector.js';
import {assert} from 'chrome://resources/js/assert.m.js';
import {afterNextRender, DomRepeat, html, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
/** @type {number} */
export const NO_SELECTION = -1;
/** @type {!Array<string>} */
export const selectorNavigationKeys = ['ArrowUp', 'ArrowDown', 'Home', 'End'];
export class InfiniteList extends PolymerElement {
static get is() {
return 'infinite-list';
}
static get template() {
return html`{__html_template__}`;
}
static get properties() {
return {
/**
* Controls the number of list items rendered initially, and added on
* demand as the component is scrolled.
*/
chunkItemCount: {
type: Number,
value: 10,
},
/**
* Controls the number of scrolled items in the currently fetched item
* chunk after which a new chunk of items should be added on demand.
*/
chunkItemThreshold: {
type: Number,
value: 5,
},
/** @type {?Array<!Object>} */
items: {
type: Array,
observer: 'onItemsChanged_',
},
};
}
constructor() {
super();
/**
* An instance of DomRepeat in charge of stamping DOM item elements.
* For performance reasons, the items property of this instance is
* modified on scroll events so that it has enough items to render
* the current scroll view.
* @private {?DomRepeat}
*/
this.domRepeat_ = null;
}
/** @override */
ready() {
super.ready();
this.ensureTemplatized_();
}
/** @private */
getDomItems_() {
const selectorChildren = this.$.selector.children;
return Array.prototype.slice.call(
selectorChildren, 0, selectorChildren.length - 1);
}
/**
* @param {number} idx
* @private
*/
isDomItemAtIndexAvailable_(idx) {
return idx < this.domRepeat_.items.length;
}
/**
* Ensure we have the required DOM items to fill the current view starting
* at the specified index.
*
* @param {number} idx
* @private
*/
ensureDomItemsAvailableStartingAt_(idx) {
if (this.domRepeat_.items.length === this.items.length) {
return;
}
const newItems = this.items.slice(
this.domRepeat_.items.length,
Math.min(idx + this.chunkItemCount, this.items.length));
if (newItems.length > 0) {
this.domRepeat_.push('items', ...newItems);
}
}
/**
* Verifies a light-dom template has been provided and initializes a DomRepeat
* component with the given template.
* @private
*/
ensureTemplatized_() {
// The user provided light-dom template to use when stamping DOM items.
const template =
/** @type {!HTMLTemplateElement} */ (this.querySelector('template'));
assert(
template,
'infinite-list requires a template to be provided in light-dom');
this.domRepeat_ = new DomRepeat();
this.domRepeat_.appendChild(template);
this.$.selector.appendChild(this.domRepeat_);
}
/**
* Adds additional DOM items as needed to fill the view based on user scroll
* interactions.
* @private
*/
onScroll_() {
if (this.$.container.scrollTop > 0 &&
this.domRepeat_.items.length !== this.items.length) {
const aboveScrollTopItemCount =
Math.round(this.$.container.scrollTop / this.domItemAverageHeight_());
// Ensure we have sufficient items to fill the current scroll position and
// a full view following our current position.
if (aboveScrollTopItemCount + this.chunkItemThreshold >
this.domRepeat_.items.length) {
this.ensureDomItemsAvailableStartingAt_(aboveScrollTopItemCount);
}
}
}
/**
* Handles key events when list item elements have focus.
* @param {!KeyboardEvent} e
* @private
*/
onKeyDown_(e) {
// Do not interfere with any parent component that manages 'shift' related
// key events.
if (e.shiftKey) {
return;
}
const selector = /** @type {!IronSelectorElement} */ (this.$.selector);
if (selector.selected === undefined) {
// No tabs matching the search text criteria.
return;
}
if (selectorNavigationKeys.includes(e.key)) {
this.navigate(e.key, true);
e.stopPropagation();
e.preventDefault();
}
}
/**
* @return {number}
* @private
*/
domItemAverageHeight_() {
if (!this.$.selector.items || this.$.selector.items.length === 0) {
return 0;
}
const domItemCount = this.$.selector.items.length;
const lastDomItem = this.$.selector.items[domItemCount - 1];
return (lastDomItem.offsetTop + lastDomItem.offsetHeight) / domItemCount;
}
/**
* Ensures that when the items property changes, only a chunk of the items
* needed to fill the current scroll position view are added to the DOM, thus
* improving rendering performance.
* @private
*/
onItemsChanged_() {
if (this.domRepeat_ && this.items) {
const domItemAvgHeight = this.domItemAverageHeight_();
const aboveScrollTopItemCount = domItemAvgHeight !== 0 ?
Math.round(this.$.container.scrollTop / domItemAvgHeight) :
0;
this.domRepeat_.set('items', []);
this.ensureDomItemsAvailableStartingAt_(aboveScrollTopItemCount);
this.updateScrollerSize_();
}
}
/**
* Sets the scroll height of the component based on an estimated average
* DOM item height and the total number of items.
* @private
*/
updateScrollerSize_() {
if (this.$.selector.items.length !== 0) {
const estScrollHeight = this.items.length * this.domItemAverageHeight_();
this.$.items.style.height = estScrollHeight + 'px';
}
}
/**
* Ensure the scroll view can fully display a preceding or following list item
* to the one selected, if existing.
* @private
*/
onSelectedChanged_() {
const selector = /** @type {!IronSelectorElement} */ (this.$.selector);
if (selector.selected === undefined) {
return;
}
const selectedIndex = /** @type{number} */ (selector.selected);
if (selectedIndex === 0 || selectedIndex === this.items.length - 1) {
/** @type {!Element} */ (selector.selectedItem).scrollIntoView({
behavior: 'smooth'
});
} else {
// If the following DOM item to the currently selected item has not yet
// been rendered, ensure it is by waiting for the next render frame
// before we scroll it into the view.
if (!this.isDomItemAtIndexAvailable_(selectedIndex + 1)) {
this.ensureDomItemsAvailableStartingAt_(selectedIndex + 1);
afterNextRender(this, this.onSelectedChanged_);
return;
}
const previousItem = selector.items[selector.selected - 1];
if (previousItem.offsetTop < this.$.container.scrollTop) {
/** @type {!Element} */ (previousItem)
.scrollIntoView({behavior: 'smooth', block: 'nearest'});
return;
}
const nextItem =
selector.items[/** @type {number} */ (selector.selected) + 1];
if (nextItem.offsetTop + nextItem.offsetHeight >
this.$.container.scrollTop + this.offsetHeight) {
/** @type {!Element} */ (nextItem).scrollIntoView(
{behavior: 'smooth', block: 'nearest'});
}
}
}
/**
* @param {string} key Keyboard event key value.
* @param {boolean=} focusItem Whether to focus the selected item.
*/
navigate(key, focusItem) {
const selector = /** @type {!IronSelectorElement} */ (this.$.selector);
if ((key === 'ArrowUp' && selector.selected === 0) || key === 'End') {
// If the DOM item to be selected has not yet been rendered, ensure it is
// by waiting for the next render frame.
const lastItemIndex = this.items.length - 1;
if (!this.isDomItemAtIndexAvailable_(lastItemIndex)) {
this.ensureDomItemsAvailableStartingAt_(lastItemIndex);
afterNextRender(
this, /** @type {function(...*)} */ (this.navigate),
[key, focusItem]);
return;
}
}
switch (key) {
case 'ArrowUp':
selector.selectPrevious();
break;
case 'ArrowDown':
selector.selectNext();
break;
case 'Home':
selector.selected = 0;
break;
case 'End':
this.$.selector.selected = this.items.length - 1;
break;
}
if (focusItem) {
selector.selectedItem.focus({preventScroll: true});
}
}
/** @param {number} index */
set selected(index) {
const selector = /** @type {!IronSelectorElement} */ (this.$.selector);
if (index !== selector.selected) {
selector.selected = index;
if (index !== NO_SELECTION) {
assert(index < this.items.length);
this.ensureDomItemsAvailableStartingAt_(index);
}
}
}
/** @return {number} The selected index or -1 if none selected. */
get selected() {
return this.$.selector.selected !== undefined ? this.$.selector.selected :
NO_SELECTION;
}
}
customElements.define(InfiniteList.is, InfiniteList);
......@@ -61,6 +61,7 @@ class TabSearchUIBrowserTest : public InProcessBrowserTest {
IN_PROC_BROWSER_TEST_F(TabSearchUIBrowserTest, InitialTabItemsListed) {
const std::string tab_items_js =
"const tabItems = document.querySelector('tab-search-app').shadowRoot"
" .getElementById('tabsList').shadowRoot"
" .querySelectorAll('tab-search-item');";
int tab_item_count =
content::EvalJs(webui_contents_.get(), tab_items_js + "tabItems.length",
......@@ -81,7 +82,7 @@ IN_PROC_BROWSER_TEST_F(TabSearchUIBrowserTest, SwitchToTabAction) {
const std::string tab_item_js = base::StringPrintf(
"document.querySelector('tab-search-app').shadowRoot"
" .getElementById('%s')",
" .getElementById('tabsList').shadowRoot.getElementById('%s')",
base::NumberToString(tab_id).c_str());
ASSERT_TRUE(content::ExecJs(webui_contents_.get(), tab_item_js + ".click()",
content::EXECUTE_SCRIPT_DEFAULT_OPTIONS,
......@@ -96,7 +97,8 @@ IN_PROC_BROWSER_TEST_F(TabSearchUIBrowserTest, CloseTabAction) {
browser()->tab_strip_model()->GetWebContentsAt(0));
const std::string tab_item_button_js = base::StringPrintf(
"document.querySelector('tab-search-app').shadowRoot.getElementById('%s')"
"document.querySelector('tab-search-app').shadowRoot"
" .getElementById('tabsList').shadowRoot.getElementById('%s')"
" .shadowRoot.getElementById('closeButton')",
base::NumberToString(tab_id).c_str());
ASSERT_TRUE(content::ExecJs(webui_contents_.get(),
......
......@@ -21,6 +21,7 @@ js_type_check("closure_compile") {
]
deps = [
":fuzzy_search_test",
":infinite_list_test",
":tab_search_app_focus_test",
":tab_search_app_test",
":tab_search_item_test",
......@@ -35,11 +36,17 @@ js_library("fuzzy_search_test") {
externs_list = [ "$externs_path/mocha-2.5.js" ]
}
js_library("infinite_list_test") {
deps = [ "..:chai_assert" ]
externs_list = [ "$externs_path/mocha-2.5.js" ]
}
js_library("tab_search_app_test") {
deps = [
":test_tab_search_api_proxy",
"..:chai_assert",
"//chrome/browser/resources/tab_search:app",
"//chrome/browser/resources/tab_search:infinite_list",
]
externs_list = [ "$externs_path/mocha-2.5.js" ]
}
......
// 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 {InfiniteList} from 'chrome://tab-search/infinite_list.js';
import {TabSearchItem} from 'chrome://tab-search/tab_search_item.js';
import {assertEquals, assertGT, assertNotEquals, assertTrue} from '../../chai_assert.js';
import {flushTasks, waitAfterNextRender} from '../../test_util.m.js';
import {generateSampleTabsFromSiteNames, sampleSiteNames} from './tab_search_test_data.js';
import {assertTabItemAndNeighborsInViewBounds, disableScrollIntoViewAnimations} from './tab_search_test_helper.js';
suite('InfniteListTest', () => {
const CHUNK_ITEM_COUNT = 7;
/** @type {!InfiniteList} */
let infiniteList;
disableScrollIntoViewAnimations(TabSearchItem);
/**
* @param {!Array<!tabSearch.mojom.Tab>} sampleData
*/
async function setupTest(sampleData) {
document.head.insertAdjacentHTML(
'beforeend', `<style>html{--list-max-height: 280px}</style>`);
document.body.innerHTML = `
<infinite-list id="list" chunk-item-count="${CHUNK_ITEM_COUNT}">
<template>
<tab-search-item id="[[item.tab.tabId]]" class="mwb-list-item"
data="[[item]]" tabindex="0" role="option">
</tab-search-item>
</template>
</infinite-list>`;
infiniteList =
/** @type {!InfiniteList} */ (document.querySelector('#list'));
infiniteList.items = sampleData;
await flushTasks();
}
/**
* @return {!NodeList<!HTMLElement>}
*/
function queryRows() {
return /** @type {!NodeList<!HTMLElement>} */ (
infiniteList.shadowRoot.querySelectorAll('tab-search-item'));
}
/**
* @param {!Array<string>} siteNames
* @return {!Array}
*/
function sampleTabItems(siteNames) {
return generateSampleTabsFromSiteNames(siteNames).map(tab => {
return {hostname: new URL(tab.url).hostname, tab};
});
}
test('ScrollHeight', async () => {
const tabItems = sampleTabItems(sampleSiteNames());
await setupTest(tabItems);
const container = infiniteList.shadowRoot.getElementById('container');
assertEquals(0, container.scrollTop);
const itemHeightStyle =
getComputedStyle(document.head).getPropertyValue('--mwb-item-height');
assertTrue(itemHeightStyle.endsWith('px'));
const tabItemHeight = Number.parseInt(
itemHeightStyle.substring(0, itemHeightStyle.length - 2), 10);
assertEquals(tabItemHeight * tabItems.length, container.scrollHeight);
});
test('SelectedIndex', async () => {
const siteNames = Array.from({length: 50}, (_, i) => 'site' + (i + 1));
const tabItems = sampleTabItems(siteNames);
await setupTest(tabItems);
const container = infiniteList.shadowRoot.getElementById('container');
assertEquals(0, container.scrollTop);
assertEquals(CHUNK_ITEM_COUNT, queryRows().length);
// Assert that upon changing the selected index to a non previously rendered
// item, this one is rendered on the view.
infiniteList.selected = CHUNK_ITEM_COUNT;
assertEquals(CHUNK_ITEM_COUNT, queryRows().length);
await waitAfterNextRender(infiniteList);
let domTabItems = queryRows();
const selectedTabItem = domTabItems[infiniteList.selected];
assertNotEquals(null, selectedTabItem);
assertEquals(2 * CHUNK_ITEM_COUNT, domTabItems.length);
// Assert that the view scrolled to show the selected item.
const afterSelectionScrollTop = container.scrollTop;
assertNotEquals(0, afterSelectionScrollTop);
// Assert that on replacing the list items, the currently selected index
// value is still rendered on the view.
infiniteList.items = sampleTabItems(siteNames);
await waitAfterNextRender(infiniteList);
domTabItems = queryRows();
const theSelectedTabItem = domTabItems[infiniteList.selected];
assertNotEquals(null, theSelectedTabItem);
// Assert the selected item is still visible in the view.
assertEquals(afterSelectionScrollTop, container.scrollTop);
});
test('NavigateDownShowsPreviousAndFollowingListItems', async () => {
const tabItems = sampleTabItems(sampleSiteNames());
await setupTest(tabItems);
const tabsDiv = /** @type {!HTMLElement} */
(infiniteList.shadowRoot.querySelector('#container'));
// Assert that the tabs are in a overflowing state.
assertGT(tabsDiv.scrollHeight, tabsDiv.clientHeight);
infiniteList.selected = 0;
for (let i = 0; i < tabItems.length; i++) {
infiniteList.navigate('ArrowDown');
await waitAfterNextRender(infiniteList);
const selectedIndex = ((i + 1) % tabItems.length);
assertEquals(selectedIndex, infiniteList.selected);
assertTabItemAndNeighborsInViewBounds(
tabsDiv, queryRows(), selectedIndex);
}
});
test('NavigateUpShowsPreviousAndFollowingListItems', async () => {
const tabItems = sampleTabItems(sampleSiteNames());
await setupTest(tabItems);
const tabsDiv = /** @type {!HTMLElement} */
(infiniteList.shadowRoot.querySelector('#container'));
// Assert that the tabs are in a overflowing state.
assertGT(tabsDiv.scrollHeight, tabsDiv.clientHeight);
infiniteList.selected = 0;
for (let i = tabItems.length; i > 0; i--) {
infiniteList.navigate('ArrowUp');
await waitAfterNextRender(infiniteList);
const selectIndex = (i - 1 + tabItems.length) % tabItems.length;
assertEquals(selectIndex, infiniteList.selected);
assertTabItemAndNeighborsInViewBounds(tabsDiv, queryRows(), selectIndex);
}
});
});
......@@ -55,7 +55,8 @@ suite('TabSearchAppFocusTest', () => {
assertEquals(searchInput, getDeepActiveElement());
const tabSearchItems = /** @type {!NodeList<!HTMLElement>} */
(tabSearchApp.shadowRoot.querySelectorAll('tab-search-item'));
(tabSearchApp.shadowRoot.querySelector('#tabsList')
.shadowRoot.querySelectorAll('tab-search-item'));
tabSearchItems[0].focus();
// Once an item is focused, arrow keys should change focus too.
......@@ -88,7 +89,8 @@ suite('TabSearchAppFocusTest', () => {
await setupTest(sampleData());
const tabSearchItem = /** @type {!HTMLElement} */
(tabSearchApp.shadowRoot.querySelector('tab-search-item'));
(tabSearchApp.shadowRoot.querySelector('#tabsList')
.shadowRoot.querySelector('tab-search-item'));
tabSearchItem.focus();
keyDownOn(tabSearchItem, 0, [], 'Enter');
......@@ -105,12 +107,14 @@ suite('TabSearchAppFocusTest', () => {
await setupTest(generateSampleDataFromSiteNames(sampleSiteNames()));
const tabsDiv = /** @type {!HTMLElement} */
(tabSearchApp.shadowRoot.querySelector('#tabs'));
(tabSearchApp.shadowRoot.querySelector('#tabsList')
.shadowRoot.querySelector('#container'));
// Assert that the tabs are in a overflowing state.
assertGT(tabsDiv.scrollHeight, tabsDiv.clientHeight);
const tabItems = /** @type {!NodeList<HTMLElement>} */
(tabSearchApp.shadowRoot.querySelectorAll('tab-search-item'));
(tabSearchApp.shadowRoot.querySelector('#tabsList')
.shadowRoot.querySelectorAll('tab-search-item'));
for (let i = 0; i < tabItems.length; i++) {
tabItems[i].focus();
......
......@@ -9,11 +9,11 @@ import {TabSearchApiProxy, TabSearchApiProxyImpl} from 'chrome://tab-search/tab_
import {TabSearchItem} from 'chrome://tab-search/tab_search_item.js';
import {TabSearchSearchField} from 'chrome://tab-search/tab_search_search_field.js';
import {assertEquals, assertFalse, assertGT, assertNotEquals, assertTrue} from '../../chai_assert.js';
import {assertEquals, assertFalse, assertNotEquals, assertTrue} from '../../chai_assert.js';
import {flushTasks, waitAfterNextRender} from '../../test_util.m.js';
import {generateSampleDataFromSiteNames, sampleData, sampleSiteNames} from './tab_search_test_data.js';
import {assertTabItemAndNeighborsInViewBounds, assertTabItemInViewBounds, disableScrollIntoViewAnimations, initLoadTimeDataWithDefaults} from './tab_search_test_helper.js';
import {sampleData} from './tab_search_test_data.js';
import {initLoadTimeDataWithDefaults} from './tab_search_test_helper.js';
import {TestTabSearchApiProxy} from './test_tab_search_api_proxy.js';
suite('TabSearchAppTest', () => {
......@@ -22,8 +22,6 @@ suite('TabSearchAppTest', () => {
/** @type {!TestTabSearchApiProxy} */
let testProxy;
disableScrollIntoViewAnimations(TabSearchItem);
/**
* @param {!NodeList<!Element>} rows
* @param {!Array<number>} ids
......@@ -39,7 +37,8 @@ suite('TabSearchAppTest', () => {
* @return {!NodeList<!Element>}
*/
function queryRows() {
return tabSearchApp.shadowRoot.querySelectorAll('tab-search-item');
return tabSearchApp.shadowRoot.querySelector('#tabsList')
.shadowRoot.querySelectorAll('tab-search-item');
}
/**
......@@ -102,7 +101,8 @@ suite('TabSearchAppTest', () => {
await setupTest({windows: [{active: true, tabs: [tabData]}]});
const tabSearchItem = /** @type {!HTMLElement} */
(tabSearchApp.shadowRoot.querySelector('tab-search-item'));
(tabSearchApp.shadowRoot.querySelector('#tabsList')
.shadowRoot.querySelector('tab-search-item'));
tabSearchItem.click();
const [tabInfo] = await testProxy.whenCalled('switchToTab');
assertEquals(tabData.tabId, tabInfo.tabId);
......@@ -208,7 +208,8 @@ suite('TabSearchAppTest', () => {
await setupTest(sampleData());
verifyTabIds(queryRows(), [1, 5, 6, 2, 3, 4]);
let tabSearchItem = /** @type {!HTMLElement} */
(tabSearchApp.shadowRoot.querySelector('tab-search-item[id="1"]'));
(tabSearchApp.shadowRoot.querySelector('#tabsList')
.shadowRoot.querySelector('tab-search-item[id="1"]'));
assertEquals('Google', tabSearchItem.data.tab.title);
assertEquals('https://www.google.com', tabSearchItem.data.tab.url);
const updatedTab = /** @type {!Tab} */ ({
......@@ -223,7 +224,8 @@ suite('TabSearchAppTest', () => {
// tabIds are not changed after tab updated.
verifyTabIds(queryRows(), [1, 5, 6, 2, 3, 4]);
tabSearchItem = /** @type {!HTMLElement} */
(tabSearchApp.shadowRoot.querySelector('tab-search-item[id="1"]'));
(tabSearchApp.shadowRoot.querySelector('#tabsList')
.shadowRoot.querySelector('tab-search-item[id="1"]'));
assertEquals(updatedTab.title, tabSearchItem.data.tab.title);
assertEquals(updatedTab.url, tabSearchItem.data.tab.url);
assertEquals('example.com', tabSearchItem.data.hostname);
......@@ -281,7 +283,8 @@ suite('TabSearchAppTest', () => {
// Click the first element with tabId 1.
let tabSearchItem = /** @type {!HTMLElement} */
(tabSearchApp.shadowRoot.querySelector('tab-search-item[id="1"]'));
(tabSearchApp.shadowRoot.querySelector('#tabsList')
.shadowRoot.querySelector('tab-search-item[id="1"]'));
tabSearchItem.click();
// Assert switchToTab() was called appropriately for an unfiltered tab list.
......@@ -302,7 +305,8 @@ suite('TabSearchAppTest', () => {
testProxy.reset();
// Click the only remaining element with tabId 2.
tabSearchItem = /** @type {!HTMLElement} */
(tabSearchApp.shadowRoot.querySelector('tab-search-item[id="2"]'));
(tabSearchApp.shadowRoot.querySelector('#tabsList')
.shadowRoot.querySelector('tab-search-item[id="2"]'));
tabSearchItem.click();
// Assert switchToTab() was called appropriately for a tab list fitlered by
......@@ -389,8 +393,9 @@ suite('TabSearchAppTest', () => {
const elements = [
tabSearchApp.shadowRoot.querySelector('#searchField'),
tabSearchApp.shadowRoot.querySelector('#tabs'),
tabSearchApp.shadowRoot.querySelector('tab-search-item'),
tabSearchApp.shadowRoot.querySelector('#tabsList'),
tabSearchApp.shadowRoot.querySelector('#tabsList')
.shadowRoot.querySelector('tab-search-item'),
tabSearchApp.shadowRoot.querySelector('#feedback-footer'),
];
......@@ -400,39 +405,4 @@ suite('TabSearchAppTest', () => {
assertEquals(4, testProxy.getCallCount('closeUI'));
});
test('Scrollbar updates show previous and following list items', async () => {
await setupTest(generateSampleDataFromSiteNames(sampleSiteNames()));
const tabsDiv = /** @type {!HTMLElement} */
(tabSearchApp.shadowRoot.querySelector('#tabs'));
// Assert that the tabs are in a overflowing state.
assertGT(tabsDiv.scrollHeight, tabsDiv.clientHeight);
const tabItems = /** @type {!NodeList<!HTMLElement>}*/ (queryRows());
const searchField = /** @type {!TabSearchSearchField} */
(tabSearchApp.shadowRoot.querySelector('#searchField'));
for (let i = 0; i < tabItems.length; i++) {
keyDownOn(searchField, 0, [], 'ArrowDown');
const selectedIndex = ((i + 1) % tabItems.length);
assertEquals(selectedIndex, tabSearchApp.getSelectedIndex());
assertTabItemAndNeighborsInViewBounds(tabsDiv, tabItems, selectedIndex);
}
keyDownOn(searchField, 0, [], 'End');
assertTabItemInViewBounds(tabsDiv, tabItems[tabItems.length - 1]);
for (let i = tabItems.length - 1; i >= 0; i--) {
keyDownOn(searchField, 0, [], 'ArrowUp');
const selectedIndex = (i - 1 + tabItems.length) % tabItems.length;
assertEquals(selectedIndex, tabSearchApp.getSelectedIndex());
assertTabItemAndNeighborsInViewBounds(tabsDiv, tabItems, selectedIndex);
}
keyDownOn(searchField, 0, [], 'Home');
assertTabItemInViewBounds(tabsDiv, tabItems[0]);
});
});
......@@ -56,6 +56,18 @@ TEST_F('FuzzySearchTest', 'All', function() {
mocha.run();
});
// eslint-disable-next-line no-var
var InfiniteListTest = class extends TabSearchBrowserTest {
/** @override */
get browsePreload() {
return 'chrome://tab-search/test_loader.html?module=tab_search/infinite_list_test.js';
}
};
TEST_F('InfiniteListTest', 'All', function() {
mocha.run();
});
// eslint-disable-next-line no-var
var TabSearchItemTest = class extends TabSearchBrowserTest {
/** @override */
......
......@@ -57,6 +57,9 @@ export function sampleData() {
return profileTabs;
}
/**
* @return {!Array<string>}
*/
export function sampleSiteNames() {
return [
'Google',
......@@ -73,11 +76,12 @@ export function sampleSiteNames() {
}
/**
* Generates profile data for a window with a series of tabs.
* Generates sample tabs based on some given site names.
* @param {!Array} siteNames
* @return {!Array}
*/
export function generateSampleDataFromSiteNames(siteNames) {
const tabs = siteNames.map((siteName, i) => {
export function generateSampleTabsFromSiteNames(siteNames) {
return siteNames.map((siteName, i) => {
return {
index: i,
tabId: i + 1,
......@@ -85,6 +89,15 @@ export function generateSampleDataFromSiteNames(siteNames) {
url: 'https://www.' + siteName.toLowerCase() + '.com',
};
});
}
return {windows: [{active: true, tabs: tabs}]};
/**
* Generates profile data for a window with a series of tabs.
* @param {!Array} siteNames
* @return {!Object}
*/
export function generateSampleDataFromSiteNames(siteNames) {
return {
windows: [{active: true, tabs: generateSampleTabsFromSiteNames(siteNames)}]
};
}
......@@ -174,7 +174,8 @@ class TabSearchStoryTop100(TabSearchStory):
SCROLL_ELEMENT_FUNCTION = '''
document.querySelector('tab-search-app').shadowRoot.querySelector('#tabs')
document.querySelector('tab-search-app').shadowRoot.getElementById('tabsList')
.shadowRoot.getElementById('container')
'''
MEASURE_FRAME_TIME_SCRIPT = '''
......
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