blob: f75375ae2e23a39fad0d6744735efb0de1da93b5 [file] [log] [blame]
// Copyright 2020 The Chromium Authors
// 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_expand_button/cr_expand_button.js';
import 'chrome://resources/cr_elements/cr_shared_vars.css.js';
import 'chrome://resources/cr_elements/mwb_element_shared_style.css.js';
import 'chrome://resources/cr_elements/mwb_shared_style.css.js';
import 'chrome://resources/cr_elements/mwb_shared_vars.css.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 './infinite_list.js';
import './tab_search_group_item.js';
import './tab_search_item.js';
import './tab_search_search_field.js';
import './title_item.js';
import './strings.m.js';
import {assert} from 'chrome://resources/js/assert_ts.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {MetricsReporter, MetricsReporterImpl} from 'chrome://resources/js/metrics_reporter/metrics_reporter.js';
import {listenOnce} from 'chrome://resources/js/util_ts.js';
import {Token} from 'chrome://resources/mojo/mojo/public/mojom/base/token.mojom-webui.js';
import {IronA11yAnnouncer} from 'chrome://resources/polymer/v3_0/iron-a11y-announcer/iron-a11y-announcer.js';
import {DomRepeatEvent, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {getTemplate} from './app.html.js';
import {fuzzySearch, FuzzySearchOptions} from './fuzzy_search.js';
import {InfiniteList, NO_SELECTION, selectorNavigationKeys} from './infinite_list.js';
import {ariaLabel, ItemData, TabData, TabGroupData, TabItemType, tokenEquals, tokenToString} from './tab_data.js';
import {ProfileData, RecentlyClosedTab, RecentlyClosedTabGroup, Tab, TabGroup, TabsRemovedInfo, TabUpdateInfo} from './tab_search.mojom-webui.js';
import {TabSearchApiProxy, TabSearchApiProxyImpl} from './tab_search_api_proxy.js';
import {TabSearchSearchField} from './tab_search_search_field.js';
import {tabHasMediaAlerts} from './tab_search_utils.js';
import {TitleItem} from './title_item.js';
// The minimum number of list items we allow viewing regardless of browser
// height. Includes a half row that hints to the user the capability to scroll.
const MINIMUM_AVAILABLE_HEIGHT_LIST_ITEM_COUNT: number = 5.5;
/**
* These values are persisted to logs and should not be renumbered or re-used.
* See tools/metrics/histograms/enums.xml.
*/
export enum TabSwitchAction {
WITHOUT_SEARCH = 0,
WITH_SEARCH = 1,
}
export interface TabSearchAppElement {
$: {
searchField: TabSearchSearchField,
tabsList: InfiniteList,
};
}
export class TabSearchAppElement extends PolymerElement {
static get is() {
return 'tab-search-app';
}
static get properties() {
return {
searchText_: {
type: String,
value: '',
},
availableHeight_: Number,
filteredItems_: {
type: Array,
value: [],
},
/**
* Options for fuzzy search. Controls how heavily weighted fields are
* relative to each other in the scoring via field weights.
*/
fuzzySearchOptions_: {
type: Object,
value: {
includeScore: true,
includeMatches: true,
ignoreLocation: false,
threshold: 0.0,
distance: 200,
keys: [
{
name: 'tab.title',
weight: 2,
},
{
name: 'hostname',
weight: 1,
},
{
name: 'tabGroup.title',
weight: 1.5,
},
],
},
},
moveActiveTabToBottom_: {
type: Boolean,
value: () => loadTimeData.getBoolean('moveActiveTabToBottom'),
},
recentlyClosedDefaultItemDisplayCount_: {
type: Number,
value: () =>
loadTimeData.getValue('recentlyClosedDefaultItemDisplayCount'),
},
searchResultText_: {type: String, value: ''},
};
}
private searchText_: string;
private availableHeight_: number;
private filteredItems_: Array<TitleItem|TabData|TabGroupData>;
private fuzzySearchOptions_: FuzzySearchOptions<TabData|TabGroupData>;
private moveActiveTabToBottom_: boolean;
private recentlyClosedDefaultItemDisplayCount_: number;
private searchResultText_: string;
private apiProxy_: TabSearchApiProxy = TabSearchApiProxyImpl.getInstance();
private metricsReporter_: MetricsReporter|null;
private useMetricsReporter_: boolean;
private listenerIds_: number[] = [];
private tabGroupsMap_: Map<string, TabGroup> = new Map();
private recentlyClosedTabGroups_: TabGroupData[] = [];
private openTabs_: TabData[] = [];
private recentlyClosedTabs_: TabData[] = [];
private windowShownTimestamp_: number = Date.now();
private mediaTabsTitleItem_: TitleItem;
private openTabsTitleItem_: TitleItem;
private recentlyClosedTitleItem_: TitleItem;
private filteredOpenTabsCount_: number = 0;
private filteredMediaTabsCount_: number = 0;
private initiallySelectedTabIndex_: number = NO_SELECTION;
private visibilityChangedListener_: () => void;
constructor() {
super();
this.visibilityChangedListener_ = () => {
// Refresh Tab Search's tab data when transitioning into a visible state.
if (document.visibilityState === 'visible') {
this.windowShownTimestamp_ = Date.now();
this.updateTabs_();
} else {
this.onDocumentHidden_();
}
};
this.mediaTabsTitleItem_ =
new TitleItem(loadTimeData.getString('mediaTabs'));
this.openTabsTitleItem_ = new TitleItem(loadTimeData.getString('openTabs'));
this.recentlyClosedTitleItem_ = new TitleItem(
loadTimeData.getString('recentlyClosed'), true /*expandable*/,
true /*expanded*/);
}
get metricsReporter(): MetricsReporter {
if (!this.metricsReporter_) {
this.metricsReporter_ = MetricsReporterImpl.getInstance();
}
return this.metricsReporter_;
}
override ready() {
super.ready();
// Update option values for fuzzy search from feature params.
this.fuzzySearchOptions_ = Object.assign({}, this.fuzzySearchOptions_, {
useFuzzySearch: loadTimeData.getBoolean('useFuzzySearch'),
ignoreLocation: loadTimeData.getBoolean('searchIgnoreLocation'),
threshold: loadTimeData.getValue('searchThreshold'),
distance: loadTimeData.getInteger('searchDistance'),
keys: [
{
name: 'tab.title',
weight: loadTimeData.getValue('searchTitleWeight'),
},
{
name: 'hostname',
weight: loadTimeData.getValue('searchHostnameWeight'),
},
{
name: 'tabGroup.title',
weight: loadTimeData.getValue('searchGroupTitleWeight'),
},
],
});
this.useMetricsReporter_ = loadTimeData.getBoolean('useMetricsReporter');
}
override connectedCallback() {
super.connectedCallback();
document.addEventListener(
'visibilitychange', this.visibilityChangedListener_);
const callbackRouter = this.apiProxy_.getCallbackRouter();
this.listenerIds_.push(
callbackRouter.tabsChanged.addListener(this.tabsChanged_.bind(this)),
callbackRouter.tabUpdated.addListener(this.onTabUpdated_.bind(this)),
callbackRouter.tabsRemoved.addListener(this.onTabsRemoved_.bind(this)));
// If added in a visible state update current tabs.
if (document.visibilityState === 'visible') {
this.updateTabs_();
}
}
override disconnectedCallback() {
super.disconnectedCallback();
this.listenerIds_.forEach(
id => this.apiProxy_.getCallbackRouter().removeListener(id));
document.removeEventListener(
'visibilitychange', this.visibilityChangedListener_);
}
/**
* @param name A property whose value is specified in pixels.
*/
private getStylePropertyPixelValue_(name: string): number {
const pxValue = getComputedStyle(this).getPropertyValue(name);
assert(pxValue);
return Number.parseInt(pxValue.trim().slice(0, -2), 10);
}
/**
* Calculate the list's available height by subtracting the height used by
* the search and feedback fields.
*/
private listMaxHeight_(height: number): number {
return Math.max(
height - this.$.searchField.offsetHeight,
Math.round(
MINIMUM_AVAILABLE_HEIGHT_LIST_ITEM_COUNT *
this.getStylePropertyPixelValue_('--mwb-item-height')));
}
private onDocumentHidden_() {
this.filteredItems_ = [];
this.$.searchField.setValue('');
this.$.searchField.getSearchInput().focus();
}
private updateTabs_() {
const getTabsStartTimestamp = Date.now();
if (this.useMetricsReporter_) {
const isMarkOverlap =
this.metricsReporter.hasLocalMark('TabListDataReceived');
chrome.metricsPrivate.recordBoolean(
'Tabs.TabSearch.WebUI.TabListDataReceived2.IsOverlap', isMarkOverlap);
if (!isMarkOverlap) {
this.metricsReporter.mark('TabListDataReceived');
}
}
this.apiProxy_.getProfileData().then(({profileData}) => {
chrome.metricsPrivate.recordTime(
'Tabs.TabSearch.WebUI.TabListDataReceived',
Math.round(Date.now() - getTabsStartTimestamp));
if (this.useMetricsReporter_) {
// TODO(crbug.com/1269417): this is a side-by-side comparison of
// metrics reporter histogram vs. old histogram. Cleanup when the
// experiment ends.
this.metricsReporter.measure('TabListDataReceived')
.then(
e => this.metricsReporter.umaReportTime(
'Tabs.TabSearch.WebUI.TabListDataReceived2', e))
.then(() => this.metricsReporter.clearMark('TabListDataReceived'))
// Ignore silently if mark 'TabListDataReceived' is missing.
.catch(() => {});
}
// The infinite-list produces viewport-filled events whenever a data or
// scroll position change triggers the the viewport fill logic.
listenOnce(this.$.tabsList, 'viewport-filled', () => {
// Push showUi() to the event loop to allow reflow to occur following
// the DOM update.
setTimeout(() => this.apiProxy_.showUi(), 0);
});
// TODO(crbug.com/c/1349350): Determine why no active window is reported
// in some cases on ChromeOS and Linux.
const activeWindow = profileData.windows.find((t) => t.active);
this.availableHeight_ =
activeWindow ? activeWindow!.height : profileData.windows[0]!.height;
this.tabsChanged_(profileData);
});
}
private onTabUpdated_(tabUpdateInfo: TabUpdateInfo) {
const {tab, inActiveWindow} = tabUpdateInfo;
const tabData = this.tabData_(
tab, inActiveWindow, TabItemType.OPEN_TAB, this.tabGroupsMap_);
// Replace the tab with the same tabId and trigger rerender.
let foundTab = false;
for (let i = 0; i < this.openTabs_.length && !foundTab; ++i) {
if (this.openTabs_[i]!.tab.tabId === tab.tabId) {
this.openTabs_[i] = tabData;
this.updateFilteredTabs_();
foundTab = true;
}
}
// If the updated tab's id is not found in the existing open tabs, add it
// to the list.
if (!foundTab) {
this.openTabs_.push(tabData);
this.updateFilteredTabs_();
}
if (this.useMetricsReporter_) {
this.metricsReporter.measure('TabUpdated')
.then(
e => this.metricsReporter.umaReportTime(
'Tabs.TabSearch.Mojo.TabUpdated', e))
.then(() => this.metricsReporter.clearMark('TabUpdated'))
// Ignore silently if mark 'TabUpdated' is missing.
.catch(() => {});
}
}
private onTabsRemoved_(tabsRemovedInfo: TabsRemovedInfo) {
if (this.openTabs_.length === 0) {
return;
}
const ids = new Set(tabsRemovedInfo.tabIds);
// Splicing in descending index order to avoid affecting preceding indices
// that are to be removed.
for (let i = this.openTabs_.length - 1; i >= 0; i--) {
if (ids.has(this.openTabs_[i]!.tab.tabId)) {
this.openTabs_.splice(i, 1);
}
}
tabsRemovedInfo.recentlyClosedTabs.forEach(tab => {
this.recentlyClosedTabs_.unshift(this.tabData_(
tab, false, TabItemType.RECENTLY_CLOSED_TAB, this.tabGroupsMap_));
});
this.updateFilteredTabs_();
}
/**
* The seleted item's index, or -1 if no item selected.
*/
getSelectedIndex(): number {
return this.$.tabsList.selected;
}
private onSearchChanged_(e: CustomEvent<string>) {
this.searchText_ = e.detail;
// Reset the selected item whenever a search query is provided.
// updateFilteredTabs_ will set the correct tab index for initial selection.
const tabsList = this.$.tabsList;
tabsList.selected = NO_SELECTION;
this.updateFilteredTabs_();
this.$.searchField.announce(this.getA11ySearchResultText_());
}
private getA11ySearchResultText_(): string {
// TODO(romanarora): Screen readers' list item number announcement will
// not match as it counts the title items too. Investigate how to
// programmatically control announcements to avoid this.
const itemCount = this.selectableItemCount_();
let text;
if (this.searchText_.length > 0) {
text = loadTimeData.getStringF(
itemCount === 1 ? 'a11yFoundTabFor' : 'a11yFoundTabsFor', itemCount,
this.searchText_);
} else {
text = loadTimeData.getStringF(
itemCount === 1 ? 'a11yFoundTab' : 'a11yFoundTabs', itemCount);
}
return text;
}
/**
* @return The number of selectable list items, excludes non
* selectable items such as section title items.
*/
private selectableItemCount_(): number {
return this.filteredItems_.reduce((acc, item) => {
return acc + (item instanceof TitleItem ? 0 : 1);
}, 0);
}
private onItemClick_(e: DomRepeatEvent<ItemData>) {
const tabItem = e.model.item;
this.tabItemAction_(tabItem, e.model.index);
}
private recordMetricsForAction(action: string, tabIndex: number) {
const withSearch = !!this.searchText_;
if (action === 'SwitchTab') {
chrome.metricsPrivate.recordEnumerationValue(
'Tabs.TabSearch.WebUI.TabSwitchAction',
withSearch ? TabSwitchAction.WITH_SEARCH :
TabSwitchAction.WITHOUT_SEARCH,
Object.keys(TabSwitchAction).length);
}
chrome.metricsPrivate.recordSmallCount(
withSearch ? `Tabs.TabSearch.WebUI.IndexOf${action}InFilteredList` :
`Tabs.TabSearch.WebUI.IndexOf${action}InUnfilteredList`,
tabIndex);
}
/**
* Trigger the click/press action associated with the given Tab item type.
*/
private tabItemAction_(itemData: ItemData, tabIndex: number) {
const state = this.searchText_ ? 'Filtered' : 'Unfiltered';
let action;
switch (itemData.type) {
case TabItemType.OPEN_TAB:
if (this.useMetricsReporter_) {
const isMarkOverlap =
this.metricsReporter.hasLocalMark('SwitchToTab');
chrome.metricsPrivate.recordBoolean(
'Tabs.TabSearch.Mojo.SwitchToTab.IsOverlap', isMarkOverlap);
if (!isMarkOverlap) {
this.metricsReporter.mark('SwitchToTab');
}
}
this.recordMetricsForAction('SwitchTab', tabIndex);
this.apiProxy_.switchToTab({tabId: (itemData as TabData).tab.tabId});
action = 'SwitchTab';
break;
case TabItemType.RECENTLY_CLOSED_TAB:
this.apiProxy_.openRecentlyClosedEntry(
(itemData as TabData).tab.tabId, !!this.searchText_, true,
tabIndex - this.filteredOpenTabsCount_);
action = 'OpenRecentlyClosedEntry';
break;
case TabItemType.RECENTLY_CLOSED_TAB_GROUP:
this.apiProxy_.openRecentlyClosedEntry(
((itemData as TabGroupData).tabGroup as RecentlyClosedTabGroup)
.sessionId,
!!this.searchText_, false, tabIndex - this.filteredOpenTabsCount_);
action = 'OpenRecentlyClosedEntry';
break;
default:
throw new Error('ItemData is of invalid type.');
}
chrome.metricsPrivate.recordTime(
`Tabs.TabSearch.WebUI.TimeTo${action}In${state}List`,
Math.round(Date.now() - this.windowShownTimestamp_));
}
private onItemClose_(e: DomRepeatEvent<TabData>) {
performance.mark('tab_search:close_tab:metric_begin');
const tabId = e.model.item.tab.tabId;
const tabIndex = e.model.index;
this.recordMetricsForAction('CloseTab', tabIndex);
this.apiProxy_.closeTab(tabId);
this.announceA11y_(loadTimeData.getString('a11yTabClosed'));
listenOnce(this.$.tabsList, 'iron-items-changed', () => {
performance.mark('tab_search:close_tab:metric_end');
});
}
private onItemKeyDown_(e: DomRepeatEvent<ItemData, KeyboardEvent>) {
if (e.key !== 'Enter' && e.key !== ' ') {
return;
}
e.stopPropagation();
e.preventDefault();
const itemData = e.model.item;
this.tabItemAction_(itemData, e.model.index);
}
private tabsChanged_(profileData: ProfileData) {
this.tabGroupsMap_ = profileData.tabGroups.reduce((map, tabGroup) => {
map.set(tokenToString(tabGroup.id), tabGroup);
return map;
}, new Map());
this.openTabs_ = profileData.windows.reduce(
(acc, {active, tabs}) => acc.concat(tabs.map(
tab => this.tabData_(
tab, active, TabItemType.OPEN_TAB, this.tabGroupsMap_))),
[] as TabData[]);
this.recentlyClosedTabs_ = profileData.recentlyClosedTabs.map(
tab => this.tabData_(
tab, false, TabItemType.RECENTLY_CLOSED_TAB, this.tabGroupsMap_));
this.recentlyClosedTabGroups_ =
profileData.recentlyClosedTabGroups.map(tabGroup => {
const tabGroupData = new TabGroupData(tabGroup);
tabGroupData.a11yTypeText =
loadTimeData.getString('a11yRecentlyClosedTabGroup');
return tabGroupData;
});
this.recentlyClosedTitleItem_.expanded =
profileData.recentlyClosedSectionExpanded;
this.updateFilteredTabs_();
}
private onItemFocus_(e: DomRepeatEvent<TabData|TabGroupData>) {
// Ensure that when a TabSearchItem receives focus, it becomes the selected
// item in the list.
this.$.tabsList.selected = e.model.index;
}
private onTitleExpandChanged_(
e: DomRepeatEvent<TitleItem, CustomEvent<{value: boolean}>>) {
// Instead of relying on two-way binding to update the `expanded` property,
// we update the value directly as the `expanded-changed` event takes place
// before a two way bound property update and we need the TitleItem
// instance to reflect the updated state prior to calling the
// updateFilteredTabs_ function.
const expanded = e.detail.value;
const titleItem = e.model.item;
titleItem.expanded = expanded;
this.apiProxy_.saveRecentlyClosedExpandedPref(expanded);
this.updateFilteredTabs_();
// If a section's title item is the last visible element in the list and the
// list's height is at its maximum, it will not be evident to the user that
// on expanding the section there are now section tab items available. By
// ensuring the first element of the section is visible, we can avoid this
// confusion.
if (expanded) {
this.$.tabsList.scrollIndexIntoView(this.filteredOpenTabsCount_);
}
e.stopPropagation();
}
/**
* Handles key events when the search field has focus.
*/
private onSearchKeyDown_(e: KeyboardEvent) {
// In the event the search field has focus and the first item in the list is
// selected and we receive a Shift+Tab navigation event, ensure All DOM
// items are available so that the focus can transfer to the last item in
// the list.
if (e.shiftKey && e.key === 'Tab' && this.$.tabsList.selected === 0) {
this.$.tabsList.ensureAllDomItemsAvailable();
return;
}
// Do not interfere with the search field's management of text selection
// that relies on the Shift key.
if (e.shiftKey) {
return;
}
if (this.getSelectedIndex() === -1) {
// No tabs matching the search text criteria.
return;
}
if (selectorNavigationKeys.includes(e.key)) {
this.$.tabsList.navigate(e.key);
e.stopPropagation();
e.preventDefault();
// TODO(tluk): Fix this to use aria-activedescendant when it's updated to
// work with Shadow DOM elements.
this.$.searchField.announce(
ariaLabel(this.$.tabsList.selectedItem as ItemData));
} else if (e.key === 'Enter') {
const itemData = this.$.tabsList.selectedItem as ItemData;
this.tabItemAction_(itemData, this.getSelectedIndex());
e.stopPropagation();
}
}
private announceA11y_(text: string) {
IronA11yAnnouncer.requestAvailability();
this.dispatchEvent(new CustomEvent(
'iron-announce', {bubbles: true, composed: true, detail: {text}}));
}
private ariaLabel_(tabData: TabData): string {
return ariaLabel(tabData);
}
private tabData_(
tab: Tab|RecentlyClosedTab, inActiveWindow: boolean, type: TabItemType,
tabGroupsMap: Map<string, TabGroup>): TabData {
const tabData = new TabData(tab, type, new URL(tab.url.url).hostname);
if (tab.groupId) {
tabData.tabGroup = tabGroupsMap.get(tokenToString(tab.groupId));
}
if (type === TabItemType.OPEN_TAB) {
tabData.inActiveWindow = inActiveWindow;
}
tabData.a11yTypeText = loadTimeData.getString(
type === TabItemType.OPEN_TAB ? 'a11yOpenTab' :
'a11yRecentlyClosedTab');
return tabData;
}
private getRecentlyClosedItemLastActiveTime_(itemData: ItemData) {
if (itemData.type === TabItemType.RECENTLY_CLOSED_TAB &&
itemData instanceof TabData) {
return (itemData.tab as RecentlyClosedTab).lastActiveTime;
}
if (itemData.type === TabItemType.RECENTLY_CLOSED_TAB_GROUP &&
itemData instanceof TabGroupData) {
return (itemData.tabGroup as RecentlyClosedTabGroup).lastActiveTime;
}
throw new Error('ItemData provided is invalid.');
}
private updateFilteredTabs_() {
this.openTabs_.sort((a, b) => {
const tabA = a.tab as Tab;
const tabB = b.tab as Tab;
// Move the active tab to the bottom of the list
// because it's not likely users want to click on it.
if (this.moveActiveTabToBottom_) {
if (a.inActiveWindow && tabA.active) {
return 1;
}
if (b.inActiveWindow && tabB.active) {
return -1;
}
}
return (tabB.lastActiveTimeTicks && tabA.lastActiveTimeTicks) ?
Number(
tabB.lastActiveTimeTicks.internalValue -
tabA.lastActiveTimeTicks.internalValue) :
0;
});
let mediaTabs: TabData[] = [];
// Audio & Video section will not be added when search criteria is applied.
// Show media tabs in Open Tabs.
if (this.searchText_.length === 0) {
mediaTabs = this.openTabs_.filter(
tabData => tabHasMediaAlerts(tabData.tab as Tab));
}
const filteredMediaTabs =
fuzzySearch(this.searchText_, mediaTabs, this.fuzzySearchOptions_);
let filteredOpenTabs =
fuzzySearch(this.searchText_, this.openTabs_, this.fuzzySearchOptions_);
// The MRU tab that is not the active tab is either the first tab in the
// Audio and Video section (if it exists) or the first tab in the Open Tabs
// section.
if (filteredOpenTabs.length > 0) {
this.initiallySelectedTabIndex_ =
tabHasMediaAlerts(filteredOpenTabs[0]!.tab! as Tab) ?
0 :
filteredMediaTabs.length;
}
if (this.searchText_.length === 0) {
filteredOpenTabs = filteredOpenTabs.filter(
tabData => !tabHasMediaAlerts(tabData.tab as Tab));
}
this.filteredOpenTabsCount_ =
filteredOpenTabs.length + filteredMediaTabs.length;
this.filteredMediaTabsCount_ = filteredMediaTabs.length;
const recentlyClosedItems: Array<TabData|TabGroupData> =
[...this.recentlyClosedTabs_, ...this.recentlyClosedTabGroups_];
recentlyClosedItems.sort((a, b) => {
const aTime = this.getRecentlyClosedItemLastActiveTime_(a);
const bTime = this.getRecentlyClosedItemLastActiveTime_(b);
return (bTime && aTime) ?
Number(bTime.internalValue - aTime.internalValue) :
0;
});
let filteredRecentlyClosedItems = fuzzySearch(
this.searchText_, recentlyClosedItems, this.fuzzySearchOptions_);
// Limit the number of recently closed items to the default display count
// when no search text has been specified. Filter out recently closed tabs
// that belong to a recently closed tab group by default.
const recentlyClosedTabGroupIds = this.recentlyClosedTabGroups_.reduce(
(acc, tabGroupData) => acc.concat(tabGroupData.tabGroup!.id),
[] as Token[]);
if (!this.searchText_.length) {
filteredRecentlyClosedItems =
filteredRecentlyClosedItems
.filter(recentlyClosedItem => {
if (recentlyClosedItem instanceof TabGroupData) {
return true;
}
const recentlyClosedTab =
(recentlyClosedItem as TabData).tab as RecentlyClosedTab;
return (
!recentlyClosedTab.groupId ||
!recentlyClosedTabGroupIds.some(
groupId =>
tokenEquals(groupId, recentlyClosedTab.groupId!)));
})
.slice(0, this.recentlyClosedDefaultItemDisplayCount_);
}
this.filteredItems_ =
([
[this.mediaTabsTitleItem_, filteredMediaTabs],
[this.openTabsTitleItem_, filteredOpenTabs],
[this.recentlyClosedTitleItem_, filteredRecentlyClosedItems],
] as Array<[TitleItem, Array<TabData|TabGroupData>]>)
.reduce((acc, [sectionTitle, sectionItems]) => {
if (sectionItems!.length !== 0) {
acc.push(sectionTitle);
if (!sectionTitle.expandable ||
sectionTitle.expandable && sectionTitle.expanded) {
acc.push(...sectionItems);
}
}
return acc;
}, [] as Array<TitleItem|TabData|TabGroupData>);
this.searchResultText_ = this.getA11ySearchResultText_();
// If there was no previously selected index, set the selected index to be
// the tab index specified for initial selection; 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.
const tabsList = this.$.tabsList;
let selectedIndex = this.getSelectedIndex();
if (selectedIndex === NO_SELECTION) {
selectedIndex = this.initiallySelectedTabIndex_;
}
tabsList.selected =
Math.min(Math.max(selectedIndex, 0), this.selectableItemCount_() - 1);
}
getSearchTextForTesting(): string {
return this.searchText_;
}
getAvailableHeightForTesting(): number {
return this.availableHeight_;
}
static get template() {
return getTemplate();
}
}
declare global {
interface HTMLElementTagNameMap {
'tab-search-app': TabSearchAppElement;
}
}
customElements.define(TabSearchAppElement.is, TabSearchAppElement);