blob: 040c0c82cb4a15408b066961ecc60c3f16b47924 [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.
/**
* @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 incrementally added to the view as the user
* scrolls through the list. The component expects a `max-height` property to be
* specified in order to determine how many HTML elements to render initially.
* The templates inside this element are used to create each list item's
* HTML element. In order to associate the templates and items, a `data-type`
* attribute is used. The `items` property specifies an array of list item data.
* The component leverages an <iron-selector> to manage item selection and
* styling. Items that should selectable must be associated with a template that
* has a `data-selectable` attribute.
*/
import 'chrome://resources/polymer/v3_0/iron-selector/iron-selector.js';
import {assert} from 'chrome://resources/js/assert_ts.js';
import {getDeepActiveElement} from 'chrome://resources/js/util_ts.js';
import {IronSelectorElement} from 'chrome://resources/polymer/v3_0/iron-selector/iron-selector.js';
import {calculateSplices, PolymerElement, TemplateInstanceBase, templatize} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {BiMap} from './bimap.js';
import {getTemplate} from './infinite_list.html.js';
export const NO_SELECTION: number = -1;
/**
* HTML class name used to recognize selectable items.
*/
const SELECTABLE_CLASS_NAME: string = 'selectable';
export const selectorNavigationKeys: readonly string[] =
Object.freeze(['ArrowUp', 'ArrowDown', 'Home', 'End']);
export interface InfiniteList {
$: {
selector: IronSelectorElement,
container: HTMLElement,
};
}
export class InfiniteList extends PolymerElement {
static get is() {
return 'infinite-list';
}
static get template() {
return getTemplate();
}
static get properties() {
return {
maxHeight: {
type: Number,
observer: 'onMaxHeightChanged_',
},
items: {
type: Array,
observer: 'onItemsChanged_',
value: [],
},
};
}
maxHeight: number;
items: Object[];
private instanceConstructors_:
Map<string,
new(args: {item: Object, index?: number}) =>
TemplateInstanceBase & HTMLElement>;
private instances_: Array<TemplateInstanceBase&HTMLElement>;
private selectableTypes_: Set<string>;
private selectableIndexToItemIndex_: BiMap<number, number>|null;
constructor() {
super();
/**
* A map of type names associated with constructors used for creating list
* item template instances.
*/
this.instanceConstructors_ = new Map();
/**
* An array of template instances each of which contain the HTMLElement
* associated with a given rendered item from the items array. The entries
* are ordered to match the item's index.
*/
this.instances_ = [];
/**
* A set of class names for which the selectable style class should be
* applied.
*/
this.selectableTypes_ = new Set();
/**
* Correlates the selectable item indexes to the `items` property indexes.
*/
this.selectableIndexToItemIndex_ = null;
}
override ready() {
super.ready();
this.ensureTemplatized_();
this.addEventListener('scroll', () => this.onScroll_());
}
/**
* Create and insert as many DOM items as necessary to ensure all items are
* rendered.
*/
ensureAllDomItemsAvailable() {
if (this.items.length > 0) {
const shouldUpdateHeight = this.instances_.length !== this.items.length;
for (let i = this.instances_.length; i < this.items.length; i++) {
this.createAndInsertDomItem_(i);
}
if (shouldUpdateHeight) {
this.updateHeight_();
}
}
}
scrollIndexIntoView(index: number) {
assert(
index >= 0 && index < this.selectableIndexToItemIndex_!.size(),
'Index is out of range.');
this.ensureSelectableDomItemAvailable_(index);
this.getSelectableDomItem_(index)!.scrollIntoView(
{behavior: 'smooth', block: 'nearest'});
}
/**
* @param key Keyboard event key value.
* @param focusItem Whether to focus the selected item.
*/
navigate(key: string, focusItem?: boolean) {
const selector = this.$.selector;
if ((key === 'ArrowUp' && selector.selected === 0) || key === 'End') {
this.ensureAllDomItemsAvailable();
selector.selected = this.selectableIndexToItemIndex_!.size() - 1;
} else {
switch (key) {
case 'ArrowUp':
selector.selectPrevious();
break;
case 'ArrowDown':
selector.selectNext();
break;
case 'Home':
selector.selected = 0;
break;
case 'End':
this.$.selector.selected =
this.selectableIndexToItemIndex_!.size() - 1;
break;
}
}
if (focusItem) {
(selector.selectedItem as HTMLElement).focus({preventScroll: true});
}
}
private ensureTemplatized_() {
// The user provided light-dom template(s) to use when stamping DOM items.
const templates = this.querySelectorAll('template');
assert(templates.length > 0, 'At least one template must be provided');
// Initialize a map of class names to template instance constructors. On
// inserting DOM nodes, a lookup will be performed against the map to
// determine the correct constructor to use for rendering a given class
// type.
templates.forEach(template => {
const type = template.dataset['type'];
assert(type);
const className = type;
if (template.dataset['selectable'] !== undefined) {
this.selectableTypes_.add(className);
}
const instanceProps = {
item: true,
// Selectable items require an `index` property to facilitate selection
// and navigation capabilities exposed through the `selected` and
// `selectedItem` properties, and the navigate method.
index: this.selectableTypes_.has(className),
};
this.instanceConstructors_.set(
className, templatize(template, this, {
parentModel: true,
instanceProps,
}) as {new (): TemplateInstanceBase & HTMLElement});
});
}
/**
* Create a DOM item and immediately insert it in the DOM tree. A reference is
* stored in the instances_ array for future item lifecycle operations.
*/
private createAndInsertDomItem_(index: number) {
const instance = this.createItemInstance_(index);
this.instances_[index] = instance;
// Offset the insertion index to take into account the template elements
// that are present in the light DOM.
this.insertBefore(
instance.root, this.children[index + this.instanceConstructors_.size]!);
}
private createItemInstance_(itemIndex: number): TemplateInstanceBase
&HTMLElement {
const item = this.items[itemIndex]!;
const instanceConstructor =
this.instanceConstructors_.get(item.constructor.name);
assert(instanceConstructor);
const itemSelectable = this.isItemSelectable_(item);
const args = itemSelectable ?
{item, index: this.selectableIndexToItemIndex_!.invGet(itemIndex)} :
{item};
const instance = new instanceConstructor(args);
if (itemSelectable) {
instance.children[0]!.classList.add(SELECTABLE_CLASS_NAME);
}
return instance;
}
/**
* @return The average DOM item height.
*/
private domItemAverageHeight_(): number {
// It must always be true that if this logic is invoked, there should be
// enough DOM items rendered to estimate an item average height. This is
// ensured by the logic that observes the items array.
const domItemCount = this.instances_.length;
assert(domItemCount);
const lastDomItem = this.lastElementChild as HTMLElement;
return (lastDomItem.offsetTop + lastDomItem.offsetHeight) / domItemCount;
}
/**
* Create and insert as many DOM items as necessary to ensure the selectable
* item at the specified index is present.
*/
private ensureSelectableDomItemAvailable_(selectableItemIndex: number) {
const itemIndex =
this.selectableIndexToItemIndex_!.get(selectableItemIndex)!;
for (let i = this.instances_.length; i < itemIndex + 1; i++) {
this.createAndInsertDomItem_(i);
}
}
private getDomItem_(index: number): HTMLElement|undefined {
const instance = this.instances_[index];
return instance!.children[0] as HTMLElement;
}
private getSelectableDomItem_(selectableItemIndex: number): HTMLElement
|undefined {
return this.getDomItem_(
this.selectableIndexToItemIndex_!.get(selectableItemIndex)!);
}
/**
* @return The number of items required to fill the current viewport.
*/
private viewportItemCount_(): number {
return Math.ceil(this.maxHeight / this.domItemAverageHeight_());
}
/**
* @return Whether DOM items were created or not.
*/
private fillViewHeight_(height: number): boolean {
const startTime = performance.now();
// Ensure we have added enough DOM items so that we are able to estimate
// item average height.
assert(this.items.length);
const initialDomItemCount = this.instances_.length;
if (initialDomItemCount === 0) {
this.createAndInsertDomItem_(0);
}
const desiredDomItemCount = Math.min(
Math.ceil(height / this.domItemAverageHeight_()), this.items.length);
// TODO(romanarora): Re-evaluate the average dom item height at given item
// insertion counts in order to determine more precisely the right number of
// items to render.
for (let i = this.instances_.length; i < desiredDomItemCount; i++) {
this.createAndInsertDomItem_(i);
}
// TODO(romanarora): Check if we have reached the desired height, and if not
// keep adding items.
if (initialDomItemCount !== desiredDomItemCount) {
performance.mark(`tab_search:infinite_list_view_updated:${
performance.now() - startTime}:metric_value`);
return true;
}
return false;
}
private isItemSelectable_(item: Object): boolean {
return this.selectableTypes_.has(item.constructor.name);
}
/**
* @return Whether a list item is selected and focused.
*/
private isItemSelectedAndFocused_(): boolean {
const selectedItemIndex = this.$.selector.selected;
if (selectedItemIndex !== undefined) {
const selectedItem =
this.getSelectableDomItem_(selectedItemIndex as number);
if (selectedItem === undefined) {
return false;
}
const deepActiveElement = getDeepActiveElement();
return selectedItem === deepActiveElement ||
(!!selectedItem.shadowRoot &&
selectedItem.shadowRoot.activeElement === deepActiveElement);
}
return false;
}
/**
* Handles key events when list item elements have focus.
*/
private onKeyDown_(e: KeyboardEvent) {
// Do not interfere with any parent component that manages 'shift' related
// key events.
if (e.shiftKey) {
return;
}
const selector = 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();
}
}
/**
* 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_(newItems: any[], oldItems: any[]) {
if (this.instanceConstructors_.size === 0) {
return;
}
if (newItems.length === 0) {
this.selectableIndexToItemIndex_ = new BiMap();
// If the new items array is empty, there is nothing to be rendered, so we
// remove any DOM items present.
this.removeDomItems_(0, this.instances_.length);
this.resetSelected_();
} else {
const itemSelectedAndFocused = this.isItemSelectedAndFocused_();
this.selectableIndexToItemIndex_ = new BiMap();
newItems.forEach((item, index) => {
if (this.isItemSelectable_(item)) {
this.selectableIndexToItemIndex_!.set(
this.selectableIndexToItemIndex_!.size(), index);
}
});
// If we had previously rendered some DOM items, we perform a partial
// update on them.
if (oldItems.length !== 0) {
// Update no more items than currently rendered and no less than what is
// required to fill the viewport.
const count =
Math.max(this.instances_.length, this.viewportItemCount_());
this.updateDomItems_(
newItems.slice(0, count), oldItems.slice(0, count));
}
this.fillViewHeight_(this.scrollTop + this.maxHeight);
// Since the new selectable items' length might be smaller than the old
// selectable items' length, we need to check if the selected index is
// still valid and if not adjust it.
const selector = this.$.selector;
if (selector.selected >= this.selectableIndexToItemIndex_!.size()) {
selector.selected = this.selectableIndexToItemIndex_!.size() - 1;
}
// Restore focus to the selected item if necessary.
if (itemSelectedAndFocused && selector.selected !== NO_SELECTION) {
this.getSelectableDomItem_(selector.selected as number)!.focus();
}
}
if (newItems.length !== oldItems.length) {
this.updateHeight_();
}
this.dispatchEvent(
new CustomEvent('viewport-filled', {bubbles: true, composed: true}));
}
private onMaxHeightChanged_(height: number) {
this.style.maxHeight = height + 'px';
}
/**
* Adds additional DOM items as needed to fill the view based on user scroll
* interactions.
*/
private onScroll_() {
const scrollTop = this.scrollTop;
if (scrollTop > 0 && this.instances_.length !== this.items.length) {
if (this.fillViewHeight_(scrollTop + this.maxHeight)) {
this.updateHeight_();
}
}
}
private updateDomItems_(newItems: any[], oldItems: any[]) {
// Identify the differences between the original and new list of items.
// These are represented as splice objects containing removed and added
// item information at a given index. We leverage these splices to change
// only the affected items.
const splices = calculateSplices(newItems, oldItems);
for (const splice of splices) {
// If the splice applies to indices for which there are no instances yet
// there is no need to update them yet.
if (splice.index >= this.instances_.length) {
continue;
}
if (splice.addedCount === splice.removed.length) {
// If the number of added and removed items are equal, reuse the
// existing DOM instances and simply update their item binding.
const indexOfLastInstance =
Math.min(splice.index + splice.addedCount, this.instances_.length);
for (let i = splice.index; i < indexOfLastInstance; i++) {
// If the types don't match, we need to replace the existing instance.
if (oldItems[i].constructor !== newItems[i].constructor) {
this.getDomItem_(i)!.remove();
this.createAndInsertDomItem_(i);
continue;
}
(this.instances_[i] as unknown as {item: any}).item = newItems[i];
}
continue;
}
// For simplicity, if new items have been added, we remove the no longer
// accurate template instances following the splice index and allow the
// component to ensure the viewport is full. If no items were added, we
// simply remove the no longer existing items and update any following
// template instances.
// TODO(romanarora): Introduce a DOM item reuse pool for a more
// efficient update.
const removeCount = splice.addedCount !== 0 ?
this.instances_.length - splice.index :
splice.removed.length;
this.removeDomItems_(splice.index, removeCount);
}
// Update the index property of the selectable item instances as it may no
// longer be accurate after the splices have taken place.
this.updateSelectableItemInstanceIndexes_();
}
private removeDomItems_(index: number, count: number) {
this.instances_.splice(index, count).forEach(instance => {
this.removeChild(instance.children[0]!);
});
}
private updateSelectableItemInstanceIndexes_() {
for (let itemIndex = 0; itemIndex < this.instances_.length; itemIndex++) {
const selectableItemIndex =
this.selectableIndexToItemIndex_!.invGet(itemIndex);
if (selectableItemIndex !== undefined) {
(this.instances_[itemIndex] as unknown as {index: number}).index =
selectableItemIndex;
}
}
}
/**
* Sets the height of the component based on an estimated average DOM item
* height and the total number of items.
*/
private updateHeight_() {
const estScrollHeight = this.items.length > 0 ?
this.items.length * this.domItemAverageHeight_() :
0;
this.$.container.style.height = estScrollHeight + 'px';
}
/**
* Ensure the scroll view can fully display a preceding or following list item
* to the one selected, if existing.
*
* TODO(romanarora): Selection navigation behavior should be configurable. The
* approach followed below might not be desired by all component users.
*/
private onSelectedChanged_() {
const selector = this.$.selector;
if (selector.selected === undefined) {
return;
}
const selectedIndex = selector.selected;
if (selectedIndex === 0) {
this.scrollTo({top: 0, behavior: 'smooth'});
return;
}
if (selectedIndex === this.selectableIndexToItemIndex_!.size() - 1) {
this.getSelectableDomItem_(selectedIndex)!.scrollIntoView(
{behavior: 'smooth'});
return;
}
const previousItem =
this.getSelectableDomItem_((selector.selected as number) - 1)!;
if (previousItem.offsetTop < this.scrollTop) {
previousItem.scrollIntoView({behavior: 'smooth', block: 'nearest'});
return;
}
const nextItemIndex = (selector.selected as number) + 1;
if (nextItemIndex < this.selectableIndexToItemIndex_!.size()) {
this.ensureSelectableDomItemAvailable_(nextItemIndex);
const nextItem = this.getSelectableDomItem_(nextItemIndex)!;
if (nextItem.offsetTop + nextItem.offsetHeight >
this.scrollTop + this.offsetHeight) {
nextItem.scrollIntoView({behavior: 'smooth', block: 'nearest'});
}
}
}
/**
* Resets the selector's selection to the undefined state. This method
* suppresses a closure validation that would require modifying the
* IronSelectableBehavior's annotations for the selected property.
*/
private resetSelected_() {
this.$.selector.selected = undefined as unknown as string | number;
}
private selectableSelector_(): string {
return '.' + SELECTABLE_CLASS_NAME;
}
set selected(index: number) {
if (index === NO_SELECTION) {
this.resetSelected_();
return;
}
const selector = this.$.selector;
if (index !== selector.selected) {
assert(
index < this.selectableIndexToItemIndex_!.size(),
'Selection index is out of range.');
this.ensureSelectableDomItemAvailable_(index);
selector.selected = index;
}
}
/** @return The selected index or -1 if none selected. */
get selected(): number {
return this.$.selector.selected !== undefined ?
this.$.selector.selected as number :
NO_SELECTION;
}
get selectedItem(): Object|null {
if (this.$.selector.selected === undefined) {
return null;
}
return this.items[this.selectableIndexToItemIndex_!.get(
this.$.selector.selected as number)!]!;
}
}
declare global {
interface HTMLElementTagNameMap {
'infinite-list': InfiniteList;
}
}
customElements.define(InfiniteList.is, InfiniteList);