|  | // 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 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.m.js'; | 
|  | import {getDeepActiveElement} from 'chrome://resources/js/util.m.js'; | 
|  | import {calculateSplices, html, PolymerElement, TemplateInstanceBase, templatize} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js'; | 
|  |  | 
|  | import {BiMap} from './bimap.js'; | 
|  |  | 
|  | /** @type {number} */ | 
|  | export const NO_SELECTION = -1; | 
|  |  | 
|  | /** | 
|  | * HTML class name used to recognize selectable items. | 
|  | * @type {string} | 
|  | */ | 
|  | const SELECTABLE_CLASS_NAME = 'selectable'; | 
|  |  | 
|  | /** @type {!Array<string>} */ | 
|  | export const selectorNavigationKeys = | 
|  | Object.freeze(['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 { | 
|  | /** @type {number} */ | 
|  | maxHeight: { | 
|  | type: Number, | 
|  | observer: 'onMaxHeightChanged_', | 
|  | }, | 
|  |  | 
|  | /** @type {!Array<!Object>} */ | 
|  | items: { | 
|  | type: Array, | 
|  | observer: 'onItemsChanged_', | 
|  | value: [], | 
|  | }, | 
|  | }; | 
|  | } | 
|  |  | 
|  | constructor() { | 
|  | super(); | 
|  |  | 
|  | /** | 
|  | * A map of type names associated with constructors used for creating list | 
|  | * item template instances. | 
|  | * @private {!Map<string, ?function(new:TemplateInstanceBase, !Object)>} | 
|  | */ | 
|  | 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. | 
|  | * @private {!Array<!TemplateInstanceBase>} | 
|  | */ | 
|  | this.instances_ = []; | 
|  |  | 
|  | /** | 
|  | * A set of class names for which the selectable style class should be | 
|  | * applied. | 
|  | * @private {!Set<string>} | 
|  | */ | 
|  | this.selectableTypes_ = new Set(); | 
|  |  | 
|  | /** | 
|  | * Correlates the selectable item indexes to the `items` property indexes. | 
|  | * @private {?BiMap<number, number>} | 
|  | */ | 
|  | 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_(); | 
|  | } | 
|  | } | 
|  | } | 
|  |  | 
|  | /** | 
|  | * @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') { | 
|  | 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.focus({preventScroll: true}); | 
|  | } | 
|  | } | 
|  |  | 
|  | ensureTemplatized_() { | 
|  | // The user provided light-dom template(s) to use when stamping DOM items. | 
|  | const templates = | 
|  | /** @type {!NodeList<!HTMLTemplateElement>} */ ( | 
|  | 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 className = assert(template.getAttribute('data-type')); | 
|  | if (template.hasAttribute('data-selectable')) { | 
|  | 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, | 
|  | })); | 
|  | }); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * 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. | 
|  | * @param {number} index | 
|  | * @private | 
|  | */ | 
|  | createAndInsertDomItem_(index) { | 
|  | const instance = this.createItemInstance_(index); | 
|  | this.instances_[index] = assert(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]); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * @param {number} itemIndex | 
|  | * @return {TemplateInstanceBase} | 
|  | * @private | 
|  | */ | 
|  | createItemInstance_(itemIndex) { | 
|  | const item = this.items[itemIndex]; | 
|  | const instanceConstructor = | 
|  | assert(this.instanceConstructors_.get(item.constructor.name)); | 
|  | 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 {number} The average DOM item height. | 
|  | * @private | 
|  | */ | 
|  | domItemAverageHeight_() { | 
|  | // 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 = assert(this.instances_.length); | 
|  | const lastDomItem = this.lastElementChild; | 
|  | 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. | 
|  | * @param {number} selectableItemIndex | 
|  | * @private | 
|  | */ | 
|  | ensureSelectableDomItemAvailable_(selectableItemIndex) { | 
|  | const itemIndex = this.selectableIndexToItemIndex_.get(selectableItemIndex); | 
|  | for (let i = this.instances_.length; i < itemIndex + 1; i++) { | 
|  | this.createAndInsertDomItem_(i); | 
|  | } | 
|  | } | 
|  |  | 
|  | /** | 
|  | * @param {number} index | 
|  | * @return {!Element} | 
|  | * @private | 
|  | */ | 
|  | getDomItem_(index) { | 
|  | return this.instances_[index].children[0]; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * @param {number} selectableItemIndex | 
|  | * @return {!Element} | 
|  | * @private | 
|  | */ | 
|  | getSelectableDomItem_(selectableItemIndex) { | 
|  | return this.getDomItem_( | 
|  | this.selectableIndexToItemIndex_.get(selectableItemIndex)); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * @return {number} The number of items required to fill the current | 
|  | *     viewport. | 
|  | */ | 
|  | viewportItemCount_() { | 
|  | return Math.ceil(this.maxHeight / this.domItemAverageHeight_()); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * @param {number} height | 
|  | * @return {boolean} Whether DOM items were created or not. | 
|  | * @private | 
|  | */ | 
|  | fillViewHeight_(height) { | 
|  | 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(`infinite_list_view_updated:${ | 
|  | performance.now() - startTime}:benchmark_value`); | 
|  |  | 
|  | return true; | 
|  | } | 
|  |  | 
|  | return false; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * @param {!Object} item | 
|  | * @return {boolean} | 
|  | */ | 
|  | isItemSelectable_(item) { | 
|  | return this.selectableTypes_.has(item.constructor.name); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * @return {boolean} Whether a list item is selected and focused. | 
|  | * @private | 
|  | */ | 
|  | isItemSelectedAndFocused_() { | 
|  | const selectedItemIndex = this.$.selector.selected; | 
|  | if (selectedItemIndex !== undefined) { | 
|  | const selectedItem = this.getSelectableDomItem_(selectedItemIndex); | 
|  | const deepActiveElement = getDeepActiveElement(); | 
|  |  | 
|  | return selectedItem === deepActiveElement || | 
|  | (selectedItem.shadowRoot && | 
|  | selectedItem.shadowRoot.activeElement === deepActiveElement); | 
|  | } | 
|  |  | 
|  | return false; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * 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(); | 
|  | } | 
|  | } | 
|  |  | 
|  | /** | 
|  | * 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. | 
|  | * @param {!Array} newItems | 
|  | * @param {!Array} oldItems | 
|  | * @private | 
|  | */ | 
|  | onItemsChanged_(newItems, oldItems) { | 
|  | 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 = /** @type {!IronSelectorElement} */ (this.$.selector); | 
|  | if (selector.selected >= this.selectableIndexToItemIndex_.size()) { | 
|  | selector.selected = this.selectableIndexToItemIndex_.size() - 1; | 
|  | } | 
|  |  | 
|  | // Restore focus to the selected item if necessary. | 
|  | if (itemSelectedAndFocused) { | 
|  | this.getSelectableDomItem_(/** @type {number} */ (selector.selected)) | 
|  | .focus(); | 
|  | } | 
|  | } | 
|  |  | 
|  | if (newItems.length !== oldItems.length) { | 
|  | this.updateHeight_(); | 
|  | } | 
|  |  | 
|  | this.dispatchEvent( | 
|  | new CustomEvent('viewport-filled', {bubbles: true, composed: true})); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * @param {number} height | 
|  | * @private | 
|  | */ | 
|  | onMaxHeightChanged_(height) { | 
|  | 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_(); | 
|  | } | 
|  | } | 
|  | } | 
|  |  | 
|  | /** | 
|  | * @param {!Array} newItems | 
|  | * @param {!Array} oldItems | 
|  | * @private | 
|  | */ | 
|  | updateDomItems_(newItems, oldItems) { | 
|  | // 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]['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_(); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * @param {number} index | 
|  | * @param {number} count | 
|  | * @private | 
|  | */ | 
|  | removeDomItems_(index, count) { | 
|  | 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]['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 = /** @type {!IronSelectorElement} */ (this.$.selector); | 
|  | if (selector.selected === undefined) { | 
|  | return; | 
|  | } | 
|  |  | 
|  | const selectedIndex = /** @type {number} */ (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 - 1); | 
|  | if (previousItem.offsetTop < this.scrollTop) { | 
|  | previousItem.scrollIntoView({behavior: 'smooth', block: 'nearest'}); | 
|  | return; | 
|  | } | 
|  |  | 
|  | const nextItemIndex = /** @type {number} */ (selector.selected) + 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. | 
|  | * @suppress {checkTypes} | 
|  | * @private | 
|  | */ | 
|  | resetSelected_() { | 
|  | /** @type {!IronSelectorElement} */ (this.$.selector).selected = undefined; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * @return {string} | 
|  | * @private | 
|  | */ | 
|  | selectableSelector_() { | 
|  | return '.' + SELECTABLE_CLASS_NAME; | 
|  | } | 
|  |  | 
|  | /** @param {number} index */ | 
|  | set selected(index) { | 
|  | if (index === NO_SELECTION) { | 
|  | this.resetSelected_(); | 
|  | return; | 
|  | } | 
|  |  | 
|  | const selector = /** @type {!IronSelectorElement} */ (this.$.selector); | 
|  | if (index !== selector.selected) { | 
|  | assert(index < this.selectableIndexToItemIndex_.size()); | 
|  | this.ensureSelectableDomItemAvailable_(index); | 
|  | selector.selected = index; | 
|  | } | 
|  | } | 
|  |  | 
|  | /** @return {number} The selected index or -1 if none selected. */ | 
|  | get selected() { | 
|  | return this.$.selector.selected !== undefined ? this.$.selector.selected : | 
|  | NO_SELECTION; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * @return {?Object} The selected item's data, if any selected. | 
|  | */ | 
|  | get selectedItem() { | 
|  | if (this.$.selector.selected === undefined) { | 
|  | return null; | 
|  | } | 
|  |  | 
|  | return this | 
|  | .items[this.selectableIndexToItemIndex_.get(this.$.selector.selected)]; | 
|  | } | 
|  | } | 
|  |  | 
|  | customElements.define(InfiniteList.is, InfiniteList); |