@scoped-vaadin/virtual-list
Version:
Web Component for displaying a virtual/infinite list of items.
243 lines (213 loc) • 8.07 kB
JavaScript
/**
* @license
* Copyright (c) 2021 - 2025 Vaadin Ltd.
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
*/
import { isChrome, isSafari } from '@scoped-vaadin/component-base/src/browser-utils.js';
import { ControllerMixin } from '@scoped-vaadin/component-base/src/controller-mixin.js';
import { OverflowController } from '@scoped-vaadin/component-base/src/overflow-controller.js';
import { processTemplates } from '@scoped-vaadin/component-base/src/templates.js';
import { Virtualizer } from '@scoped-vaadin/component-base/src/virtualizer.js';
/**
* @polymerMixin
* @mixes ControllerMixin
*/
export const VirtualListMixin = (superClass) =>
class VirtualListMixinClass extends ControllerMixin(superClass) {
static get properties() {
return {
/**
* An array containing items determining how many instances to render.
* @type {Array<!VirtualListItem> | undefined}
*/
items: { type: Array, sync: true },
/**
* Custom function for rendering the content of every item.
* Receives three arguments:
*
* - `root` The render target element representing one item at a time.
* - `virtualList` The reference to the `<vaadin24-virtual-list>` element.
* - `model` The object with the properties related with the rendered
* item, contains:
* - `model.index` The index of the rendered item.
* - `model.item` The item.
* @type {VirtualListRenderer | undefined}
*/
renderer: { type: Function, sync: true },
/**
* A function that generates accessible names for virtual list items.
* The function gets the item as an argument and the
* return value should be a string representing that item. The
* result gets applied to the corresponding virtual list child element
* as an `aria-label` attribute.
*/
itemAccessibleNameGenerator: {
type: Function,
sync: true,
},
/** @private */
__virtualizer: Object,
};
}
static get observers() {
return ['__itemsOrRendererChanged(items, renderer, __virtualizer, itemAccessibleNameGenerator)'];
}
/**
* Gets the index of the first visible item in the viewport.
*
* @return {number}
*/
get firstVisibleIndex() {
return this.__virtualizer.firstVisibleIndex;
}
/**
* Gets the index of the last visible item in the viewport.
*
* @return {number}
*/
get lastVisibleIndex() {
return this.__virtualizer.lastVisibleIndex;
}
constructor() {
super();
this.__onDocumentDragStart = this.__onDocumentDragStart.bind(this);
}
/** @protected */
ready() {
super.ready();
this.__virtualizer = new Virtualizer({
createElements: this.__createElements,
updateElement: this.__updateElement.bind(this),
elementsContainer: this,
scrollTarget: this,
scrollContainer: this.shadowRoot.querySelector('#items'),
reorderElements: true,
});
this.__overflowController = new OverflowController(this);
this.addController(this.__overflowController);
processTemplates(this);
this.__updateAria();
}
/** @protected */
connectedCallback() {
super.connectedCallback();
document.addEventListener('dragstart', this.__onDocumentDragStart, { capture: true });
this.__virtualizer.hostConnected();
}
/** @protected */
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('dragstart', this.__onDocumentDragStart, { capture: true });
}
/**
* Scroll to a specific index in the virtual list.
*
* @param {number} index Index to scroll to
*/
scrollToIndex(index) {
this.__virtualizer.scrollToIndex(index);
}
/** @private */
__createElements(count) {
return [...Array(count)].map(() => document.createElement('div'));
}
/** @private */
__updateAria() {
this.role = 'list';
}
/** @private */
__updateElement(el, index) {
const item = this.items[index];
el.ariaSetSize = String(this.items.length);
el.ariaPosInSet = String(index + 1);
el.ariaLabel = this.itemAccessibleNameGenerator ? this.itemAccessibleNameGenerator(item) : null;
this.__updateElementRole(el);
if (el.__renderer !== this.renderer) {
el.__renderer = this.renderer;
this.__clearRenderTargetContent(el);
}
if (this.renderer) {
this.renderer(el, this, { item, index });
}
}
/** @private */
__updateElementRole(el) {
el.role = 'listitem';
}
/**
* Clears the content of a render target.
* @private
*/
__clearRenderTargetContent(element) {
element.innerHTML = '';
// Whenever a Lit-based renderer is used, it assigns a Lit part to the node it was rendered into.
// When clearing the rendered content, this part needs to be manually disposed of.
// Otherwise, using a Lit-based renderer on the same node will throw an exception or render nothing afterward.
delete element._$litPart$;
}
/** @private */
__itemsOrRendererChanged(items, renderer, virtualizer) {
// If the renderer is removed but there are elements created by
// a previous renderer, we need to request an update from the virtualizer
// to get the already existing elements properly cleared.
const hasRenderedItems = this.childElementCount > 0;
if ((renderer || hasRenderedItems) && virtualizer) {
virtualizer.size = (items || []).length;
virtualizer.update();
}
}
/**
* Webkit-based browsers have issues with generating drag images
* for elements that have children with massive heights. Chromium
* browsers crash, while Safari experiences significant performance
* issues. To mitigate these issues, we hide the items container
* when drag starts to remove it from the drag image.
*
* Virtual lists with fewer rows also have issues on Chromium and Safari
* where the drag image is not properly clipped and may include
* content outside the virtual list. Temporary inline styles are applied
* to mitigate this issue.
*
* Related issues:
* - https://github.com/vaadin/web-components/issues/7985
* - https://issues.chromium.org/issues/383356871
* - https://github.com/vaadin/web-components/issues/8386
*
* @private
*/
__onDocumentDragStart(e) {
if (e.target.contains(this)) {
// Record the original inline styles to restore them later
const elements = [e.target, this.$.items];
const originalInlineStyles = elements.map((element) => element.style.cssText);
// With a large number of rows, hide the items
if (this.scrollHeight > 20000) {
this.$.items.style.display = 'none';
}
// Workaround content outside the virtual list ending up in the drag image on Chromium
if (isChrome) {
e.target.style.willChange = 'transform';
}
// Workaround text content outside the virtual list ending up in the drag image on Safari
if (isSafari) {
this.$.items.style.maxHeight = '100%';
}
requestAnimationFrame(() => {
elements.forEach((element, index) => {
element.style.cssText = originalInlineStyles[index];
});
});
}
}
/**
* Requests an update for the content of the rows.
* While performing the update, it invokes the renderer passed in the `renderer` property for each visible row.
*
* It is not guaranteed that the update happens immediately (synchronously) after it is requested.
*/
requestContentUpdate() {
if (this.__virtualizer) {
this.__virtualizer.update();
}
}
};