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") { ...@@ -54,6 +54,7 @@ preprocess_grit("preprocess_web_components") {
out_manifest = "$target_gen_dir/$preprocess_web_components_manifest" out_manifest = "$target_gen_dir/$preprocess_web_components_manifest"
in_files = [ in_files = [
"app.js", "app.js",
"infinite_list.js",
"tab_search_item.js", "tab_search_item.js",
"tab_search_search_field.js", "tab_search_search_field.js",
] ]
...@@ -103,6 +104,7 @@ js_type_check("closure_compile") { ...@@ -103,6 +104,7 @@ js_type_check("closure_compile") {
deps = [ deps = [
":app", ":app",
":fuzzy_search", ":fuzzy_search",
":infinite_list",
":tab_data", ":tab_data",
":tab_search_api_proxy", ":tab_search_api_proxy",
":tab_search_item", ":tab_search_item",
...@@ -118,7 +120,6 @@ js_library("app") { ...@@ -118,7 +120,6 @@ js_library("app") {
":tab_search_item", ":tab_search_item",
":tab_search_search_field", ":tab_search_search_field",
"//third_party/polymer/v3_0/components-chromium/iron-a11y-announcer", "//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", "//third_party/polymer/v3_0/components-chromium/polymer:polymer_bundled",
"//ui/webui/resources/js:cr.m", "//ui/webui/resources/js:cr.m",
"//ui/webui/resources/js:load_time_data.m", "//ui/webui/resources/js:load_time_data.m",
...@@ -134,6 +135,13 @@ js_library("fuzzy_search") { ...@@ -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") { js_library("tab_data") {
deps = [] deps = []
} }
...@@ -166,6 +174,7 @@ js_library("tab_search_search_field") { ...@@ -166,6 +174,7 @@ js_library("tab_search_search_field") {
html_to_js("web_components") { html_to_js("web_components") {
js_files = [ js_files = [
"app.js", "app.js",
"infinite_list.js",
"tab_search_item.js", "tab_search_item.js",
"tab_search_search_field.js", "tab_search_search_field.js",
] ]
......
<style include="mwb-shared-style"> <style include="mwb-shared-style">
#tabs { #tabsList {
max-height: 280px; --list-max-height: 280px;
overflow-x: hidden;
overflow-y: auto;
position: relative;
}
#tabsContainer {
height: calc(var(--mwb-item-height) * var(--item-count, 0));
} }
#no-results { #no-results {
...@@ -62,20 +55,17 @@ ...@@ -62,20 +55,17 @@
label="$i18n{searchTabs}" on-focus="onSearchFocus_" label="$i18n{searchTabs}" on-focus="onSearchFocus_"
on-keydown="onSearchKeyDown_" on-search-changed="onSearchChanged_"> on-keydown="onSearchKeyDown_" on-search-changed="onSearchChanged_">
</tab-search-search-field> </tab-search-search-field>
<div id="tabs"> <div hidden="[[!filteredOpenTabs_.length]]">
<div id="tabsContainer"> <infinite-list id="tabsList" items="[[filteredOpenTabs_]]" >
<iron-selector id="selector" on-keydown="onItemKeyDown_" <template>
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)]]" <tab-search-item id="[[item.tab.tabId]]" aria-label="[[ariaLabel_(item)]]"
class="mwb-list-item" data="[[item]]" class="mwb-list-item" data="[[item]]"
on-click="onItemClick_" on-close="onItemClose_" on-click="onItemClick_" on-close="onItemClose_"
on-focus="onItemFocus_" tabindex="0" role="option"> on-focus="onItemFocus_" on-keydown="onItemKeyDown_" tabindex="0"
role="option">
</tab-search-item> </tab-search-item>
</template> </template>
</iron-selector> </infinite-list>
</div>
</div> </div>
<div id="no-results" hidden="[[filteredOpenTabs_.length]]"> <div id="no-results" hidden="[[filteredOpenTabs_.length]]">
$i18n{noResultsFound} $i18n{noResultsFound}
......
...@@ -8,24 +8,22 @@ import 'chrome://resources/cr_elements/mwb_shared_style.js'; ...@@ -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/cr_elements/mwb_shared_vars.js';
import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.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-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_item.js';
import './tab_search_search_field.js'; import './tab_search_search_field.js';
import './strings.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 {loadTimeData} from 'chrome://resources/js/load_time_data.m.js';
import {listenOnce} from 'chrome://resources/js/util.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 {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 {fuzzySearch} from './fuzzy_search.js';
import {InfiniteList, NO_SELECTION, selectorNavigationKeys} from './infinite_list.js';
import {TabData} from './tab_data.js'; import {TabData} from './tab_data.js';
import {Tab, WindowTabs} from './tab_search.mojom-webui.js'; import {Tab, WindowTabs} from './tab_search.mojom-webui.js';
import {TabSearchApiProxy, TabSearchApiProxyImpl} from './tab_search_api_proxy.js'; import {TabSearchApiProxy, TabSearchApiProxyImpl} from './tab_search_api_proxy.js';
const selectorNavigationKeys = ['ArrowUp', 'ArrowDown', 'Home', 'End'];
export class TabSearchAppElement extends PolymerElement { export class TabSearchAppElement extends PolymerElement {
static get is() { static get is() {
return 'tab-search-app'; return 'tab-search-app';
...@@ -55,16 +53,6 @@ export class TabSearchAppElement extends PolymerElement { ...@@ -55,16 +53,6 @@ export class TabSearchAppElement extends PolymerElement {
value: [], 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. * Options for fuzzy search.
* @private {!Object} * @private {!Object}
...@@ -160,26 +148,13 @@ export class TabSearchAppElement extends PolymerElement { ...@@ -160,26 +148,13 @@ export class TabSearchAppElement extends PolymerElement {
// Prior to the first load |this.openTabs_| has not been set. Record the // Prior to the first load |this.openTabs_| has not been set. Record the
// time it takes for the initial list of tabs to render. // time it takes for the initial list of tabs to render.
if (!this.openTabs_) { if (!this.openTabs_) {
listenOnce(this.$.tabsList, 'rendered-item-count-changed', e => { listenOnce(this.$.tabsList, 'dom-change', () => {
const event = /** @type {!CustomEvent<!{value: number}>} */ (e); afterNextRender(this, () => {
// 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(() => {
this.apiProxy_.showUI(); this.apiProxy_.showUI();
chrome.metricsPrivate.recordTime( chrome.metricsPrivate.recordTime(
'Tabs.TabSearch.WebUI.InitialTabsRenderTime', 'Tabs.TabSearch.WebUI.InitialTabsRenderTime',
Math.round(window.performance.now())); Math.round(window.performance.now()));
}, 0); });
}); });
} }
this.openTabs_ = profileTabs.windows; this.openTabs_ = profileTabs.windows;
...@@ -228,10 +203,7 @@ export class TabSearchAppElement extends PolymerElement { ...@@ -228,10 +203,7 @@ export class TabSearchAppElement extends PolymerElement {
* @return {number} * @return {number}
*/ */
getSelectedIndex() { getSelectedIndex() {
const selector = /** @type {!IronSelectorElement} */ (this.$.selector); return /** @type {!InfiniteList} */ (this.$.tabsList).selected;
return selector.selected !== undefined ?
/** @type {number} */ (selector.selected) :
-1;
} }
/** /**
...@@ -243,9 +215,8 @@ export class TabSearchAppElement extends PolymerElement { ...@@ -243,9 +215,8 @@ export class TabSearchAppElement extends PolymerElement {
this.updateFilteredTabs_(this.openTabs_ || []); this.updateFilteredTabs_(this.openTabs_ || []);
// Reset the selected item whenever a search query is provided. // Reset the selected item whenever a search query is provided.
this.$.selector.selected = /** @type {!InfiniteList} */ (this.$.tabsList).selected =
this.filteredOpenTabs_.length > 0 ? 0 : undefined; this.filteredOpenTabs_.length > 0 ? 0 : NO_SELECTION;
this.$.tabs.scrollTop = 0;
const length = this.filteredOpenTabs_.length; const length = this.filteredOpenTabs_.length;
let text; let text;
...@@ -267,7 +238,7 @@ export class TabSearchAppElement extends PolymerElement { ...@@ -267,7 +238,7 @@ export class TabSearchAppElement extends PolymerElement {
/** @private */ /** @private */
onFeedbackFocus_() { onFeedbackFocus_() {
this.$.selector.selected = undefined; /** @type {!InfiniteList} */ (this.$.tabsList).selected = NO_SELECTION;
} }
/** /**
...@@ -288,11 +259,27 @@ export class TabSearchAppElement extends PolymerElement { ...@@ -288,11 +259,27 @@ export class TabSearchAppElement extends PolymerElement {
const tabId = Number.parseInt(e.currentTarget.id, 10); const tabId = Number.parseInt(e.currentTarget.id, 10);
this.apiProxy_.closeTab(tabId); this.apiProxy_.closeTab(tabId);
this.announceA11y_(loadTimeData.getString('a11yTabClosed')); this.announceA11y_(loadTimeData.getString('a11yTabClosed'));
listenOnce(this.$.tabsList, 'rendered-item-count-changed', () => { listenOnce(this.$.tabsList, 'iron-items-changed', () => {
performance.mark('close_tab:benchmark_end'); 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 * @param {!Array<!WindowTabs>} newOpenTabs
* @private * @private
...@@ -304,9 +291,10 @@ export class TabSearchAppElement extends PolymerElement { ...@@ -304,9 +291,10 @@ export class TabSearchAppElement extends PolymerElement {
// selected; else retain the currently selected index. If the list // selected; else retain the currently selected index. If the list
// shrunk above the selected index, select the last index in 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. // 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), Math.max(this.getSelectedIndex(), 0),
this.filteredOpenTabs_.length - 1)); this.filteredOpenTabs_.length - 1);
} }
/** /**
...@@ -314,67 +302,18 @@ export class TabSearchAppElement extends PolymerElement { ...@@ -314,67 +302,18 @@ export class TabSearchAppElement extends PolymerElement {
* @private * @private
*/ */
onItemFocus_(e) { 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); 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 */ /** @private */
onSearchFocus_() { onSearchFocus_() {
if (this.$.selector.selected === undefined && const tabsList = /** @type {!InfiniteList} */ (this.$.tabsList);
if (tabsList.selected === NO_SELECTION &&
this.filteredOpenTabs_.length > 0) { this.filteredOpenTabs_.length > 0) {
this.$.selector.selectIndex(0); tabsList.selected = 0;
} }
} }
...@@ -407,8 +346,8 @@ export class TabSearchAppElement extends PolymerElement { ...@@ -407,8 +346,8 @@ export class TabSearchAppElement extends PolymerElement {
} }
if (selectorNavigationKeys.includes(e.key)) { if (selectorNavigationKeys.includes(e.key)) {
this.selectorNavigate_(e.key); /** @type {!InfiniteList} */ (this.$.tabsList).navigate(e.key);
this.updateScrollView_();
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
...@@ -459,42 +398,6 @@ export class TabSearchAppElement extends PolymerElement { ...@@ -459,42 +398,6 @@ export class TabSearchAppElement extends PolymerElement {
0); 0);
this.filteredOpenTabs_ = this.filteredOpenTabs_ =
fuzzySearch(this.searchText_, result, this.fuzzySearchOptions_); 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} */ /** 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>
This diff is collapsed.
...@@ -61,6 +61,7 @@ class TabSearchUIBrowserTest : public InProcessBrowserTest { ...@@ -61,6 +61,7 @@ class TabSearchUIBrowserTest : public InProcessBrowserTest {
IN_PROC_BROWSER_TEST_F(TabSearchUIBrowserTest, InitialTabItemsListed) { IN_PROC_BROWSER_TEST_F(TabSearchUIBrowserTest, InitialTabItemsListed) {
const std::string tab_items_js = const std::string tab_items_js =
"const tabItems = document.querySelector('tab-search-app').shadowRoot" "const tabItems = document.querySelector('tab-search-app').shadowRoot"
" .getElementById('tabsList').shadowRoot"
" .querySelectorAll('tab-search-item');"; " .querySelectorAll('tab-search-item');";
int tab_item_count = int tab_item_count =
content::EvalJs(webui_contents_.get(), tab_items_js + "tabItems.length", content::EvalJs(webui_contents_.get(), tab_items_js + "tabItems.length",
...@@ -81,7 +82,7 @@ IN_PROC_BROWSER_TEST_F(TabSearchUIBrowserTest, SwitchToTabAction) { ...@@ -81,7 +82,7 @@ IN_PROC_BROWSER_TEST_F(TabSearchUIBrowserTest, SwitchToTabAction) {
const std::string tab_item_js = base::StringPrintf( const std::string tab_item_js = base::StringPrintf(
"document.querySelector('tab-search-app').shadowRoot" "document.querySelector('tab-search-app').shadowRoot"
" .getElementById('%s')", " .getElementById('tabsList').shadowRoot.getElementById('%s')",
base::NumberToString(tab_id).c_str()); base::NumberToString(tab_id).c_str());
ASSERT_TRUE(content::ExecJs(webui_contents_.get(), tab_item_js + ".click()", ASSERT_TRUE(content::ExecJs(webui_contents_.get(), tab_item_js + ".click()",
content::EXECUTE_SCRIPT_DEFAULT_OPTIONS, content::EXECUTE_SCRIPT_DEFAULT_OPTIONS,
...@@ -96,7 +97,8 @@ IN_PROC_BROWSER_TEST_F(TabSearchUIBrowserTest, CloseTabAction) { ...@@ -96,7 +97,8 @@ IN_PROC_BROWSER_TEST_F(TabSearchUIBrowserTest, CloseTabAction) {
browser()->tab_strip_model()->GetWebContentsAt(0)); browser()->tab_strip_model()->GetWebContentsAt(0));
const std::string tab_item_button_js = base::StringPrintf( 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')", " .shadowRoot.getElementById('closeButton')",
base::NumberToString(tab_id).c_str()); base::NumberToString(tab_id).c_str());
ASSERT_TRUE(content::ExecJs(webui_contents_.get(), ASSERT_TRUE(content::ExecJs(webui_contents_.get(),
......
...@@ -21,6 +21,7 @@ js_type_check("closure_compile") { ...@@ -21,6 +21,7 @@ js_type_check("closure_compile") {
] ]
deps = [ deps = [
":fuzzy_search_test", ":fuzzy_search_test",
":infinite_list_test",
":tab_search_app_focus_test", ":tab_search_app_focus_test",
":tab_search_app_test", ":tab_search_app_test",
":tab_search_item_test", ":tab_search_item_test",
...@@ -35,11 +36,17 @@ js_library("fuzzy_search_test") { ...@@ -35,11 +36,17 @@ js_library("fuzzy_search_test") {
externs_list = [ "$externs_path/mocha-2.5.js" ] 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") { js_library("tab_search_app_test") {
deps = [ deps = [
":test_tab_search_api_proxy", ":test_tab_search_api_proxy",
"..:chai_assert", "..:chai_assert",
"//chrome/browser/resources/tab_search:app", "//chrome/browser/resources/tab_search:app",
"//chrome/browser/resources/tab_search:infinite_list",
] ]
externs_list = [ "$externs_path/mocha-2.5.js" ] 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', () => { ...@@ -55,7 +55,8 @@ suite('TabSearchAppFocusTest', () => {
assertEquals(searchInput, getDeepActiveElement()); assertEquals(searchInput, getDeepActiveElement());
const tabSearchItems = /** @type {!NodeList<!HTMLElement>} */ const tabSearchItems = /** @type {!NodeList<!HTMLElement>} */
(tabSearchApp.shadowRoot.querySelectorAll('tab-search-item')); (tabSearchApp.shadowRoot.querySelector('#tabsList')
.shadowRoot.querySelectorAll('tab-search-item'));
tabSearchItems[0].focus(); tabSearchItems[0].focus();
// Once an item is focused, arrow keys should change focus too. // Once an item is focused, arrow keys should change focus too.
...@@ -88,7 +89,8 @@ suite('TabSearchAppFocusTest', () => { ...@@ -88,7 +89,8 @@ suite('TabSearchAppFocusTest', () => {
await setupTest(sampleData()); await setupTest(sampleData());
const tabSearchItem = /** @type {!HTMLElement} */ const tabSearchItem = /** @type {!HTMLElement} */
(tabSearchApp.shadowRoot.querySelector('tab-search-item')); (tabSearchApp.shadowRoot.querySelector('#tabsList')
.shadowRoot.querySelector('tab-search-item'));
tabSearchItem.focus(); tabSearchItem.focus();
keyDownOn(tabSearchItem, 0, [], 'Enter'); keyDownOn(tabSearchItem, 0, [], 'Enter');
...@@ -105,12 +107,14 @@ suite('TabSearchAppFocusTest', () => { ...@@ -105,12 +107,14 @@ suite('TabSearchAppFocusTest', () => {
await setupTest(generateSampleDataFromSiteNames(sampleSiteNames())); await setupTest(generateSampleDataFromSiteNames(sampleSiteNames()));
const tabsDiv = /** @type {!HTMLElement} */ const tabsDiv = /** @type {!HTMLElement} */
(tabSearchApp.shadowRoot.querySelector('#tabs')); (tabSearchApp.shadowRoot.querySelector('#tabsList')
.shadowRoot.querySelector('#container'));
// Assert that the tabs are in a overflowing state. // Assert that the tabs are in a overflowing state.
assertGT(tabsDiv.scrollHeight, tabsDiv.clientHeight); assertGT(tabsDiv.scrollHeight, tabsDiv.clientHeight);
const tabItems = /** @type {!NodeList<HTMLElement>} */ 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++) { for (let i = 0; i < tabItems.length; i++) {
tabItems[i].focus(); tabItems[i].focus();
......
...@@ -9,11 +9,11 @@ import {TabSearchApiProxy, TabSearchApiProxyImpl} from 'chrome://tab-search/tab_ ...@@ -9,11 +9,11 @@ import {TabSearchApiProxy, TabSearchApiProxyImpl} from 'chrome://tab-search/tab_
import {TabSearchItem} from 'chrome://tab-search/tab_search_item.js'; import {TabSearchItem} from 'chrome://tab-search/tab_search_item.js';
import {TabSearchSearchField} from 'chrome://tab-search/tab_search_search_field.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 {flushTasks, waitAfterNextRender} from '../../test_util.m.js';
import {generateSampleDataFromSiteNames, sampleData, sampleSiteNames} from './tab_search_test_data.js'; import {sampleData} from './tab_search_test_data.js';
import {assertTabItemAndNeighborsInViewBounds, assertTabItemInViewBounds, disableScrollIntoViewAnimations, initLoadTimeDataWithDefaults} from './tab_search_test_helper.js'; import {initLoadTimeDataWithDefaults} from './tab_search_test_helper.js';
import {TestTabSearchApiProxy} from './test_tab_search_api_proxy.js'; import {TestTabSearchApiProxy} from './test_tab_search_api_proxy.js';
suite('TabSearchAppTest', () => { suite('TabSearchAppTest', () => {
...@@ -22,8 +22,6 @@ suite('TabSearchAppTest', () => { ...@@ -22,8 +22,6 @@ suite('TabSearchAppTest', () => {
/** @type {!TestTabSearchApiProxy} */ /** @type {!TestTabSearchApiProxy} */
let testProxy; let testProxy;
disableScrollIntoViewAnimations(TabSearchItem);
/** /**
* @param {!NodeList<!Element>} rows * @param {!NodeList<!Element>} rows
* @param {!Array<number>} ids * @param {!Array<number>} ids
...@@ -39,7 +37,8 @@ suite('TabSearchAppTest', () => { ...@@ -39,7 +37,8 @@ suite('TabSearchAppTest', () => {
* @return {!NodeList<!Element>} * @return {!NodeList<!Element>}
*/ */
function queryRows() { 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', () => { ...@@ -102,7 +101,8 @@ suite('TabSearchAppTest', () => {
await setupTest({windows: [{active: true, tabs: [tabData]}]}); await setupTest({windows: [{active: true, tabs: [tabData]}]});
const tabSearchItem = /** @type {!HTMLElement} */ const tabSearchItem = /** @type {!HTMLElement} */
(tabSearchApp.shadowRoot.querySelector('tab-search-item')); (tabSearchApp.shadowRoot.querySelector('#tabsList')
.shadowRoot.querySelector('tab-search-item'));
tabSearchItem.click(); tabSearchItem.click();
const [tabInfo] = await testProxy.whenCalled('switchToTab'); const [tabInfo] = await testProxy.whenCalled('switchToTab');
assertEquals(tabData.tabId, tabInfo.tabId); assertEquals(tabData.tabId, tabInfo.tabId);
...@@ -208,7 +208,8 @@ suite('TabSearchAppTest', () => { ...@@ -208,7 +208,8 @@ suite('TabSearchAppTest', () => {
await setupTest(sampleData()); await setupTest(sampleData());
verifyTabIds(queryRows(), [1, 5, 6, 2, 3, 4]); verifyTabIds(queryRows(), [1, 5, 6, 2, 3, 4]);
let tabSearchItem = /** @type {!HTMLElement} */ 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('Google', tabSearchItem.data.tab.title);
assertEquals('https://www.google.com', tabSearchItem.data.tab.url); assertEquals('https://www.google.com', tabSearchItem.data.tab.url);
const updatedTab = /** @type {!Tab} */ ({ const updatedTab = /** @type {!Tab} */ ({
...@@ -223,7 +224,8 @@ suite('TabSearchAppTest', () => { ...@@ -223,7 +224,8 @@ suite('TabSearchAppTest', () => {
// tabIds are not changed after tab updated. // tabIds are not changed after tab updated.
verifyTabIds(queryRows(), [1, 5, 6, 2, 3, 4]); verifyTabIds(queryRows(), [1, 5, 6, 2, 3, 4]);
tabSearchItem = /** @type {!HTMLElement} */ 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.title, tabSearchItem.data.tab.title);
assertEquals(updatedTab.url, tabSearchItem.data.tab.url); assertEquals(updatedTab.url, tabSearchItem.data.tab.url);
assertEquals('example.com', tabSearchItem.data.hostname); assertEquals('example.com', tabSearchItem.data.hostname);
...@@ -281,7 +283,8 @@ suite('TabSearchAppTest', () => { ...@@ -281,7 +283,8 @@ suite('TabSearchAppTest', () => {
// Click the first element with tabId 1. // Click the first element with tabId 1.
let tabSearchItem = /** @type {!HTMLElement} */ 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(); tabSearchItem.click();
// Assert switchToTab() was called appropriately for an unfiltered tab list. // Assert switchToTab() was called appropriately for an unfiltered tab list.
...@@ -302,7 +305,8 @@ suite('TabSearchAppTest', () => { ...@@ -302,7 +305,8 @@ suite('TabSearchAppTest', () => {
testProxy.reset(); testProxy.reset();
// Click the only remaining element with tabId 2. // Click the only remaining element with tabId 2.
tabSearchItem = /** @type {!HTMLElement} */ tabSearchItem = /** @type {!HTMLElement} */
(tabSearchApp.shadowRoot.querySelector('tab-search-item[id="2"]')); (tabSearchApp.shadowRoot.querySelector('#tabsList')
.shadowRoot.querySelector('tab-search-item[id="2"]'));
tabSearchItem.click(); tabSearchItem.click();
// Assert switchToTab() was called appropriately for a tab list fitlered by // Assert switchToTab() was called appropriately for a tab list fitlered by
...@@ -389,8 +393,9 @@ suite('TabSearchAppTest', () => { ...@@ -389,8 +393,9 @@ suite('TabSearchAppTest', () => {
const elements = [ const elements = [
tabSearchApp.shadowRoot.querySelector('#searchField'), tabSearchApp.shadowRoot.querySelector('#searchField'),
tabSearchApp.shadowRoot.querySelector('#tabs'), tabSearchApp.shadowRoot.querySelector('#tabsList'),
tabSearchApp.shadowRoot.querySelector('tab-search-item'), tabSearchApp.shadowRoot.querySelector('#tabsList')
.shadowRoot.querySelector('tab-search-item'),
tabSearchApp.shadowRoot.querySelector('#feedback-footer'), tabSearchApp.shadowRoot.querySelector('#feedback-footer'),
]; ];
...@@ -400,39 +405,4 @@ suite('TabSearchAppTest', () => { ...@@ -400,39 +405,4 @@ suite('TabSearchAppTest', () => {
assertEquals(4, testProxy.getCallCount('closeUI')); 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() { ...@@ -56,6 +56,18 @@ TEST_F('FuzzySearchTest', 'All', function() {
mocha.run(); 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 // eslint-disable-next-line no-var
var TabSearchItemTest = class extends TabSearchBrowserTest { var TabSearchItemTest = class extends TabSearchBrowserTest {
/** @override */ /** @override */
......
...@@ -57,6 +57,9 @@ export function sampleData() { ...@@ -57,6 +57,9 @@ export function sampleData() {
return profileTabs; return profileTabs;
} }
/**
* @return {!Array<string>}
*/
export function sampleSiteNames() { export function sampleSiteNames() {
return [ return [
'Google', 'Google',
...@@ -73,11 +76,12 @@ export function sampleSiteNames() { ...@@ -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 * @param {!Array} siteNames
* @return {!Array}
*/ */
export function generateSampleDataFromSiteNames(siteNames) { export function generateSampleTabsFromSiteNames(siteNames) {
const tabs = siteNames.map((siteName, i) => { return siteNames.map((siteName, i) => {
return { return {
index: i, index: i,
tabId: i + 1, tabId: i + 1,
...@@ -85,6 +89,15 @@ export function generateSampleDataFromSiteNames(siteNames) { ...@@ -85,6 +89,15 @@ export function generateSampleDataFromSiteNames(siteNames) {
url: 'https://www.' + siteName.toLowerCase() + '.com', 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): ...@@ -174,7 +174,8 @@ class TabSearchStoryTop100(TabSearchStory):
SCROLL_ELEMENT_FUNCTION = ''' 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 = ''' 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