From 8857776d7a5f5fd28417680f271b4008e4c4e4ef Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Mon, 11 May 2026 17:23:50 +0300 Subject: [PATCH] feat: Added virtualization component --- src/components/chat/chat.spec.ts | 12 +- src/components/common/utils.spec.ts | 12 + src/components/virtualization/engine.ts | 223 +++++++++ src/components/virtualization/types.ts | 54 ++ .../virtualization/virtualization.spec.ts | 189 +++++++ .../virtualization/virtualization.ts | 414 ++++++++++++++++ src/index.ts | 10 + stories/virtual-scroll.stories.ts | 467 ++++++++++++++++++ 8 files changed, 1371 insertions(+), 10 deletions(-) create mode 100644 src/components/virtualization/engine.ts create mode 100644 src/components/virtualization/types.ts create mode 100644 src/components/virtualization/virtualization.spec.ts create mode 100644 src/components/virtualization/virtualization.ts create mode 100644 stories/virtual-scroll.stories.ts diff --git a/src/components/chat/chat.spec.ts b/src/components/chat/chat.spec.ts index a8416bba8..76f49516b 100644 --- a/src/components/chat/chat.spec.ts +++ b/src/components/chat/chat.spec.ts @@ -14,6 +14,7 @@ import { simulateFocus, simulateInput, simulateKeyboard, + suppressResizeObserverLoopError, } from '../common/utils.spec.js'; import { simulateFileUpload } from '../file-input/file-input.spec.js'; import IgcInputComponent from '../input/input.js'; @@ -33,16 +34,7 @@ import type { describe('Chat', () => { before(() => { defineComponents(IgcChatComponent, IgcInputComponent); - - // Suppress ResizeObserver loop errors that can occur during tests from - // the underlying igc-textarea component. These errors do not affect the tests and are not actionable. - const errorHandler = window.onerror; - window.onerror = (message, ...args) => { - if (typeof message === 'string' && message.match(/ResizeObserver loop/)) { - return true; - } - return errorHandler ? errorHandler(message, ...args) : false; - }; + suppressResizeObserverLoopError(); }); const textInputTemplate = (text: string) => html` diff --git a/src/components/common/utils.spec.ts b/src/components/common/utils.spec.ts index 7d2aaea0e..303cbf722 100644 --- a/src/components/common/utils.spec.ts +++ b/src/components/common/utils.spec.ts @@ -488,3 +488,15 @@ export function compareStyles( export function checkDatesEqual(a: CalendarDay | Date, b: CalendarDay | Date) { expect(toCalendarDay(a).equalTo(toCalendarDay(b))).to.be.true; } + +export function suppressResizeObserverLoopError(): void { + // Suppress ResizeObserver loop errors that can occur during tests. + // These are benign and do not affect test correctness. + const errorHandler = window.onerror; + window.onerror = (message, ...args) => { + if (typeof message === 'string' && message.match(/ResizeObserver loop/)) { + return true; + } + return errorHandler ? errorHandler(message, ...args) : false; + }; +} diff --git a/src/components/virtualization/engine.ts b/src/components/virtualization/engine.ts new file mode 100644 index 000000000..3abb79bef --- /dev/null +++ b/src/components/virtualization/engine.ts @@ -0,0 +1,223 @@ +/** + * Probes the browser for the maximum scrollable coordinate it supports. + */ +function getMaxBrowserSizeProbePx(doc: Document): number { + const div = doc.createElement('div'); + div.style.position = 'absolute'; + div.style.top = `${Number.MAX_SAFE_INTEGER}px`; + doc.body.appendChild(div); + const size = Math.abs(div.getBoundingClientRect().top); + doc.body.removeChild(div); + return size; +} + +/** + * Builds a prefix sums array from the given sizes array. + * The prefix sums array has one more element than the sizes array, + * where the first element is 0 and each subsequent element is the sum of all previous sizes. + * This allows for efficient calculation of the total size up to any index in the sizes array. + */ +function buildPrefixSums(sizes: readonly number[]): number[] { + const sums = new Array(sizes.length + 1); + sums[0] = 0; + for (let i = 0; i < sizes.length; i++) { + sums[i + 1] = sums[i] + sizes[i]; + } + return sums; +} + +/** + * Performs a binary search on the prefix sums array to find the largest index such that prefixSums[index] <= target. + * This is used to efficiently determine how many items can fit within a given scroll position. + * The function returns the index of the last item that fits within the target scroll position. + * If the target is smaller than the first prefix sum, it returns -1, indicating that no items fit. + */ +function binarySearchPrefixSums( + prefixSums: readonly number[], + target: number +): number { + let low = 0; + let high = prefixSums.length - 1; + + while (low < high) { + const mid = (low + high + 1) >> 1; + if (prefixSums[mid] <= target) { + low = mid; + } else { + high = mid - 1; + } + } + + return Math.max(0, low - 1); +} + +/** + * Describes the currently visible (and over-scanned) range of items. + */ +export interface VisibleRange { + /** Index of the first rendered item (inclusive) */ + startIndex: number; + /** Index of the last rendered item (inclusive) */ + endIndex: number; +} + +/** + * Pure scroll-math engine for a single axis of virtual scrolling. + * + * All size state is held as plain arrays. Consumers can register an + * `onSizeChange` callback to react whenever item sizes or the item count + * changes (e.g. to trigger a Lit `requestUpdate()`). + */ +export class VirtualScrollEngine { + private _maxBrowserSize = Number.POSITIVE_INFINITY; + + /** + * The ratio `totalSize / maxBrowserSize` when `totalSize` exceeds the + * maximum DOM coordinate the browser supports; `1` otherwise. + * Used to map virtual scroll positions to DOM scroll positions. + */ + private _virtualRatio = 1; + + /** Per-item measured or estimated sizes in px. */ + private _itemSizes: number[] = []; + + /** Cached prefix sums, rebuilt lazily when `_prefixSumsDirty` is set. */ + private _prefixSums: number[] = [0]; + private _prefixSumsDirty = false; + + /** + * Called whenever item sizes or the item count change. + * Assign a callback (e.g. `() => this.requestUpdate()`) to react to size updates. + */ + public onSizeChange: (() => void) | null = null; + + /** + * Prefix-sum array of item sizes, where prefixSums[i] is the total size of items[0] through items[i-1]. + */ + public get prefixSums(): number[] { + if (this._prefixSumsDirty) { + this._prefixSums = buildPrefixSums(this._itemSizes); + this._prefixSumsDirty = false; + } + return this._prefixSums; + } + + /** Total virtual size of all items in px. */ + public get totalSize(): number { + const pSum = this.prefixSums; + return pSum[pSum.length - 1] ?? 0; + } + + /** Actual DOM space size (clamped to the maximum browser size) */ + public get domSize(): number { + return this._virtualRatio !== 1 ? this._maxBrowserSize : this.totalSize; + } + + /** + * Initializes the maximum browser size by probing the document, and updates the virtual ratio accordingly. + */ + public initMaxBrowserSize(doc: Document): void { + this._maxBrowserSize = getMaxBrowserSizeProbePx(doc); + this._updateVirtualRatio(); + } + + /** + * Grows or shrinks the internal sizes array to `length`. + * New entries are filled with `estimatedSize`. + * Existing measured sizes are preserved. + */ + public resize(length: number, estimatedSize: number): void { + const current = this._itemSizes; + if (length === current.length) return; + + if (length > current.length) { + current.push(...new Array(length - current.length).fill(estimatedSize)); + } else { + current.length = length; + } + this._invalidate(); + this.onSizeChange?.(); + } + + /** + * Records the measured DOM size for a single item. + */ + public measureItem(index: number, size: number): void { + if (index < 0 || index >= this._itemSizes.length) return; + if (this._itemSizes[index] === size) return; + + this._itemSizes[index] = size; + this._invalidate(); + this.onSizeChange?.(); + } + + /** + * Returns the DOM scroll offset in pixels that brings item at `index` into view + * at the leading edge of the viewport. + */ + public getScrollOffsetForIndex(index: number): number { + const pSums = this.prefixSums; + if (index <= 0) return 0; + + const clamped = Math.min(index, pSums.length - 1); + const virtualOffset = pSums[clamped]; + return virtualOffset / this._virtualRatio; + } + + /** Returns the item index at the given DOM scroll position. */ + public getIndexAtScroll(scrollPosition: number): number { + const virtualPosition = scrollPosition * this._virtualRatio; + const pSum = this.prefixSums; + if (virtualPosition <= 0 || pSum.length <= 1) return 0; + + return binarySearchPrefixSums(pSum, virtualPosition); + } + + /** + * Returns the visible + over-scanned item range for the given scroll state. + */ + public getVisibleRange( + scrollPosition: number, + viewportSize: number, + overScan: number, + totalItems: number + ): VisibleRange { + if (totalItems === 0 || viewportSize <= 0) { + return { startIndex: 0, endIndex: -1 }; + } + + const start = Math.max(0, this.getIndexAtScroll(scrollPosition) - overScan); + const endScrollPosition = scrollPosition + viewportSize; + const endRaw = this.getIndexAtScroll(endScrollPosition); + const end = Math.min(totalItems - 1, endRaw + overScan); + + return { startIndex: start, endIndex: end }; + } + + /** + * Returns the CSS `translateY` / `translateX` value (px) to apply to the + * absolutely-positioned content wrapper. + * + * The content wrapper is `position: absolute; top: 0; left: 0` inside a + * track element that is `totalSize` px tall/wide. Translating it to + * `getContentPosition(startIndex)` places the first rendered item exactly + * at its virtual scroll position within the track. + */ + public getContentPosition(index: number): number { + return this.getScrollOffsetForIndex(index); + } + + private _invalidate(): void { + this._prefixSumsDirty = true; + this._updateVirtualRatio(); + } + + private _updateVirtualRatio(): void { + const totalSize = this.totalSize; + this._virtualRatio = + this._maxBrowserSize === Number.POSITIVE_INFINITY || + totalSize <= this._maxBrowserSize + ? 1 + : totalSize / this._maxBrowserSize; + } +} diff --git a/src/components/virtualization/types.ts b/src/components/virtualization/types.ts new file mode 100644 index 000000000..ae0f57745 --- /dev/null +++ b/src/components/virtualization/types.ts @@ -0,0 +1,54 @@ +/** + * Context for the item template in the virtual scroll component. + * Provides the item data, its index, and utility properties for template rendering. + */ +export class VirtualScrollItemContext { + /** The current item in the virtual scroll */ + public value: T; + /** The index of the current item */ + public index: number; + /** The total number of items */ + public count: number; + + constructor(value: T, index: number, count: number) { + this.value = value; + this.index = index; + this.count = count; + } + + /** Whether the current item is the first item */ + public get isFirst(): boolean { + return this.index === 0; + } + + /** Whether the current item is the last item */ + public get isLast(): boolean { + return this.index === this.count - 1; + } +} + +/** Snapshot of the currently rendered virtual window */ +export interface VirtualScrollState { + /** The index of the first item currently rendered in the viewport. */ + startIndex: number; + /** The index of the last item currently rendered in the viewport (inclusive). */ + endIndex: number; + /** The size of the viewport in pixels. */ + viewportSize: number; + /** The total size of the virtual scroll content in pixels. */ + totalSize: number; +} + +/** + * Request for more data to be loaded in the virtual scroll, typically emitted when the user scrolls near the end of the currently loaded items. + * The consumer of the virtual scroll component can listen to this event and load more data as needed. + */ +export interface VirtualScrollDataRequest { + /** + * The first index that does not yet have data. + * Append at least `(endIndex - startIndex + 1)` more items starting here. + */ + startIndex: number; + /** Number of items being requested. */ + count: number; +} diff --git a/src/components/virtualization/virtualization.spec.ts b/src/components/virtualization/virtualization.spec.ts new file mode 100644 index 000000000..02b7b409b --- /dev/null +++ b/src/components/virtualization/virtualization.spec.ts @@ -0,0 +1,189 @@ +import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; +import { spy } from 'sinon'; +import { defineComponents } from '../common/definitions/defineComponents.js'; +import { suppressResizeObserverLoopError } from '../common/utils.spec.js'; +import type { VirtualScrollItemContext } from './types.js'; +import IgcVirtualScrollComponent, { + type VirtualScrollItemTemplate, +} from './virtualization.js'; + +describe('VirtualScroll', () => { + before(() => { + defineComponents(IgcVirtualScrollComponent); + suppressResizeObserverLoopError(); + }); + + function createItems(count: number): string[] { + return Array.from({ length: count }, (_, i) => `Item ${i}`); + } + + const itemTemplate: VirtualScrollItemTemplate = ( + ctx: VirtualScrollItemContext + ) => html`${ctx.value}`; + + describe('Accessibility', () => { + it('passes the a11y audit', async () => { + const el = await fixture>( + html`` + ); + + await expect(el).lightDom.to.be.accessible(); + }); + }); + + describe('Default values', () => { + let el: IgcVirtualScrollComponent; + + beforeEach(async () => { + el = await fixture>( + html`` + ); + }); + + it('initializes with correct defaults', () => { + expect(el.data).to.deep.equal([]); + expect(el.orientation).to.equal('vertical'); + expect(el.overScan).to.equal(2); + expect(el.estimatedItemSize).to.equal(50); + expect(el.itemTemplate).to.be.null; + }); + }); + + describe('Orientation', () => { + it('reflects orientation attribute', async () => { + const el = await fixture( + html`` + ); + + expect(el.orientation).to.equal('horizontal'); + expect(el.getAttribute('orientation')).to.equal('horizontal'); + }); + + it('defaults orientation to vertical', async () => { + const el = await fixture( + html`` + ); + + expect(el.getAttribute('orientation')).to.equal('vertical'); + }); + }); + + describe('Rendering', () => { + it('renders nothing without an itemTemplate', async () => { + const el = await fixture>( + html`` + ); + + const content = el.querySelector('.igc-vs__content'); + expect(content).to.be.null; + }); + + it('renders the track and content divs when itemTemplate is set', async () => { + const el = await fixture>( + html`` + ); + + expect(el.querySelector('.igc-vs__track')).to.not.be.null; + expect(el.querySelector('.igc-vs__content')).to.not.be.null; + }); + }); + + describe('Events', () => { + it('emits igcStateChange after render with data and itemTemplate', async () => { + const el = await fixture>( + html`` + ); + + const eventSpy = spy(el, 'emitEvent'); + + el.data = createItems(50); + el.itemTemplate = itemTemplate; + await elementUpdated(el); + + expect(eventSpy).calledWith('igcStateChange'); + }); + + it('emits igcDataRequest when scrolled near the end of data', async () => { + const items = createItems(8); + const el = await fixture>( + html`` + ); + + const eventSpy = spy(el, 'emitEvent'); + + // Trigger a re-render by updating data to a count that puts end near threshold + el.data = createItems(4); + await elementUpdated(el); + + expect(eventSpy).calledWith('igcDataRequest'); + }); + }); + + describe('Public API', () => { + it('scrollToIndex sets scrollTop for vertical orientation', async () => { + const el = await fixture>( + html`` + ); + + await elementUpdated(el); + el.scrollToIndex(100); + + expect(el.scrollTop).to.be.greaterThan(0); + }); + + it('scrollToIndex sets scrollLeft for horizontal orientation', async () => { + const el = await fixture>( + html`` + ); + + await elementUpdated(el); + el.scrollToIndex(100); + + expect(el.scrollLeft).to.be.greaterThan(0); + }); + }); + + describe('Engine integration', () => { + it('resizes track when data changes', async () => { + const el = await fixture>( + html`` + ); + + const trackBefore = el.querySelector('.igc-vs__track'); + expect(trackBefore?.style.height).to.equal(`${10 * 50}px`); + + el.data = createItems(20); + await elementUpdated(el); + + const trackAfter = el.querySelector('.igc-vs__track'); + expect(trackAfter?.style.height).to.equal(`${20 * 50}px`); + }); + }); +}); diff --git a/src/components/virtualization/virtualization.ts b/src/components/virtualization/virtualization.ts new file mode 100644 index 000000000..ae1979f90 --- /dev/null +++ b/src/components/virtualization/virtualization.ts @@ -0,0 +1,414 @@ +import { + html, + LitElement, + nothing, + type PropertyValues, + type TemplateResult, +} from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { createRef, ref } from 'lit/directives/ref.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { createResizeObserverController } from '../common/controllers/resize-observer.js'; +import { registerComponent } from '../common/definitions/register.js'; +import type { Constructor } from '../common/mixins/constructor.js'; +import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; +import { asNumber } from '../common/util.js'; +import { VirtualScrollEngine } from './engine.js'; +import { + type VirtualScrollDataRequest, + VirtualScrollItemContext, + type VirtualScrollState, +} from './types.js'; + +export type VirtualScrollItemTemplate = ( + context: VirtualScrollItemContext +) => TemplateResult | typeof nothing; + +export interface IgcVirtualScrollComponentEventMap { + igcStateChange: CustomEvent; + igcDataRequest: CustomEvent; +} + +const REMOTE_SCROLLING_THRESHOLD = 5; + +/** + * A virtual scroll component that efficiently renders large lists by only + * rendering the items currently visible in the viewport. + * + * @element igc-virtual-scroll + * + * @fires igcStateChange - Emitted after each render pass with a snapshot of the current virtual window. + * @fires igcDataRequest - Emitted when the scroll position approaches the end of the available data. + */ +export default class IgcVirtualScrollComponent< + T = unknown, +> extends EventEmitterMixin< + IgcVirtualScrollComponentEventMap, + Constructor +>(LitElement) { + public static readonly tagName = 'igc-virtual-scroll'; + + /* blazorSuppress */ + public static register(): void { + registerComponent(IgcVirtualScrollComponent); + } + + //#region Internal state + + protected readonly _engine = new VirtualScrollEngine(); + + private readonly _contentRef = createRef(); + private _itemResizeObserver: ResizeObserver | null = null; + private _onScroll: ((e: Event) => void) | null = null; + + @state() + private _scrollPosition = 0; + + @state() + private _viewportSize = 0; + + //#endregion + + //#region Public properties + + /** + * The array of items to virtualize. + */ + @property({ attribute: false }) + public data: T[] = []; + + /** + * Scroll orientation of the virtual scroll. + * @attr + * @default 'vertical' + */ + @property({ reflect: true }) + public orientation: 'vertical' | 'horizontal' = 'vertical'; + + /** + * Number of extra items to render beyond the visible area of the viewport. + * Higher values reduce blank flashes during fast scrolling but may impact performance. + * @attr over-scan + * @default 2 + */ + @property({ type: Number, attribute: 'over-scan' }) + public overScan = 2; + + /** + * Estimated item size in pixels used before an item is measured in the DOM. + * The engine replaces this with the actual measured size after the first render of each item. + * @attr estimated-item-size + * @default 50 + */ + @property({ type: Number, attribute: 'estimated-item-size' }) + public estimatedItemSize = 50; + + /** + * A function that renders each item in the virtual scroll list. + * Receives a VirtualScrollItemContext with the item data, its index, and the total count. + * If not provided, nothing is rendered. + */ + @property({ attribute: false }) + public itemTemplate: VirtualScrollItemTemplate | null = null; + + //#endregion + + private static _styleSheet: CSSStyleSheet | null = null; + + private static _getStyleSheet(): CSSStyleSheet { + if (!IgcVirtualScrollComponent._styleSheet) { + const sheet = new CSSStyleSheet(); + sheet.replaceSync(/* css */ ` + igc-virtual-scroll { + display: block; + position: relative; + overflow: auto; + } + + igc-virtual-scroll[orientation='vertical'] { + overflow-y: auto; + overflow-x: hidden; + } + + igc-virtual-scroll[orientation='horizontal'] { + overflow-x: auto; + overflow-y: hidden; + } + + .igc-vs__track { + position: relative; + width: 100%; + min-height: 100%; + } + + .igc-vs__content { + position: absolute; + top: 0; + left: 0; + width: 100%; + will-change: transform; + contain: layout style paint; + } + + igc-virtual-scroll[orientation='horizontal'] .igc-vs__track { + height: 100%; + width: auto; + min-height: unset; + } + + igc-virtual-scroll[orientation='horizontal'] .igc-vs__content { + display: flex; + flex-direction: row; + height: 100%; + width: auto; + } + + igc-virtual-scroll[orientation='horizontal'] .igc-vs__content > [data-vs-index] { + flex-shrink: 0; + height: 100%; + } + `); + IgcVirtualScrollComponent._styleSheet = sheet; + } + return IgcVirtualScrollComponent._styleSheet; + } + + private _adoptStyles(): void { + const root = this.getRootNode() as Document | ShadowRoot; + const sheet = IgcVirtualScrollComponent._getStyleSheet(); + if (!root.adoptedStyleSheets.includes(sheet)) { + root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet]; + } + } + + constructor() { + super(); + this._engine.onSizeChange = () => this.requestUpdate(); + this._handleItemResize = this._handleItemResize.bind(this); + + // Viewport resize observer + createResizeObserverController(this, { + callback: this._handleViewportResize, + }); + } + + //#region Lit lifecycle + + /** @internal */ + public override createRenderRoot(): HTMLElement | DocumentFragment { + return this; + } + + /** @internal */ + public override connectedCallback(): void { + super.connectedCallback(); + this._adoptStyles(); + this._engine.initMaxBrowserSize(document); + this._measureViewport(); + this._setupScrollListener(); + } + + /** @internal */ + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this._dispose(); + } + + protected override willUpdate(changed: PropertyValues): void { + if (changed.has('data') || changed.has('estimatedItemSize')) { + this._engine.resize(this.data.length, this.estimatedItemSize); + } + + if (changed.has('orientation')) { + this._measureViewport(); + this._setupScrollListener(); + } + } + + protected override updated(_changed: PropertyValues): void { + this._scheduleItemMeasurement(); + this._checkDataRequest(); + + const range = this._visibleRange; + if (range.endIndex >= range.startIndex) { + this.emitEvent('igcStateChange', { + detail: { + startIndex: range.startIndex, + endIndex: range.endIndex, + viewportSize: this._viewportSize, + totalSize: this._engine.totalSize, + }, + }); + } + } + + protected override render(): TemplateResult { + if (!this.itemTemplate) { + return html`${nothing}`; + } + + const range = this._visibleRange; + const count = this.data.length; + const isVertical = this._isVertical; + + const trackStyle = isVertical + ? { height: `${this._engine.domSize}px` } + : { width: `${this._engine.domSize}px` }; + + const contentPosition = this._engine.getContentPosition(range.startIndex); + const contentStyle = { + transform: isVertical + ? `translateY(${contentPosition}px)` + : `translateX(${contentPosition}px)`, + }; + + const visibleItems = + range.endIndex >= range.startIndex + ? this.data.slice(range.startIndex, range.endIndex + 1) + : []; + + return html` + + `; + } + + //#endregion + + //#region Internal API + + private get _isVertical(): boolean { + return this.orientation === 'vertical'; + } + + private get _visibleRange() { + return this._engine.getVisibleRange( + this._scrollPosition, + this._viewportSize, + this.overScan, + this.data.length + ); + } + + private _measureViewport(): void { + const size = this._isVertical ? this.clientHeight : this.clientWidth; + if (size !== this._viewportSize) { + this._viewportSize = size; + } + } + + private _setupScrollListener(): void { + if (this._onScroll) { + this.removeEventListener('scroll', this._onScroll); + } + + this._onScroll = (e: Event) => { + const target = e.target as HTMLElement; + const scrollPos = this._isVertical ? target.scrollTop : target.scrollLeft; + this._scrollPosition = scrollPos; + }; + + this.addEventListener('scroll', this._onScroll, { passive: true }); + } + + private _handleViewportResize(): void { + const newSize = this._isVertical ? this.clientHeight : this.clientWidth; + if (newSize !== this._viewportSize) { + this._viewportSize = newSize; + } + } + + private _handleItemResize(entries: ResizeObserverEntry[]): void { + for (const entry of entries) { + const el = entry.target as HTMLElement; + const index = asNumber(el.dataset.vsIndex, -1); + if (index < 0) continue; + + const measured = this._isVertical + ? (entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height) + : (entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width); + + if (measured > 0) { + this._engine.measureItem(index, measured); + } + } + } + + private _scheduleItemMeasurement(): void { + if (!this._contentRef.value) return; + + this._itemResizeObserver?.disconnect(); + this._itemResizeObserver = new ResizeObserver(this._handleItemResize); + + for (const el of this._contentRef.value.children) { + this._itemResizeObserver.observe(el); + } + } + + private _checkDataRequest(): void { + const range = this._visibleRange; + const total = this.data.length; + + if (total > 0 && range.endIndex >= total - REMOTE_SCROLLING_THRESHOLD) { + this.emitEvent('igcDataRequest', { + detail: { + startIndex: total, + count: Math.max(this.overScan * 4, 20), + }, + }); + } + } + + private _dispose(): void { + if (this._onScroll) { + this.removeEventListener('scroll', this._onScroll); + this._onScroll = null; + } + this._itemResizeObserver?.disconnect(); + } + + //#endregion + + //#region Public API + + /** Programmatically scrolls to the specified item index. */ + public scrollToIndex(index: number): void { + const offset = this._engine.getScrollOffsetForIndex(index); + + if (this._isVertical) { + this.scrollTop = offset; + } else { + this.scrollLeft = offset; + } + } + + //#endregion +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-virtual-scroll': IgcVirtualScrollComponent; + } +} diff --git a/src/index.ts b/src/index.ts index 94c52e11e..0abf16e04 100644 --- a/src/index.ts +++ b/src/index.ts @@ -72,6 +72,16 @@ export { default as IgcStepComponent } from './components/stepper/step.js'; export { default as IgcHighlightComponent } from './components/highlight/highlight.js'; export { default as IgcTooltipComponent } from './components/tooltip/tooltip.js'; export { default as IgcThemeProviderComponent } from './components/theme-provider/theme-provider.js'; +export { default as IgcVirtualScrollComponent } from './components/virtualization/virtualization.js'; +export type { + IgcVirtualScrollComponentEventMap, + VirtualScrollItemTemplate, +} from './components/virtualization/virtualization.js'; +export type { + VirtualScrollItemContext, + VirtualScrollState, + VirtualScrollDataRequest, +} from './components/virtualization/types.js'; // definitions export { defineComponents } from './components/common/definitions/defineComponents.js'; diff --git a/stories/virtual-scroll.stories.ts b/stories/virtual-scroll.stories.ts new file mode 100644 index 000000000..818affb37 --- /dev/null +++ b/stories/virtual-scroll.stories.ts @@ -0,0 +1,467 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html, nothing } from 'lit'; + +import { + IgcAvatarComponent, + IgcBadgeComponent, + IgcCardComponent, + IgcChipComponent, + IgcLinearProgressComponent, + IgcListComponent, + IgcListHeaderComponent, + IgcListItemComponent, + IgcVirtualScrollComponent, + type VirtualScrollItemContext, + type VirtualScrollItemTemplate, + defineComponents, +} from 'igniteui-webcomponents'; +import { disableStoryControls } from './story.js'; + +defineComponents( + IgcVirtualScrollComponent, + IgcListComponent, + IgcListHeaderComponent, + IgcListItemComponent, + IgcAvatarComponent, + IgcChipComponent, + IgcBadgeComponent, + IgcCardComponent, + IgcLinearProgressComponent +); + +// region default +const metadata: Meta = { + title: 'VirtualScroll', + component: 'igc-virtual-scroll', + parameters: { + docs: { + description: { + component: + 'A virtual scroll component that efficiently renders large lists by only\nrendering the items currently visible in the viewport.', + }, + }, + actions: { handles: ['igcStateChange', 'igcDataRequest'] }, + }, + argTypes: { + orientation: { + type: '"vertical" | "horizontal"', + description: 'Scroll orientation of the virtual scroll.', + options: ['vertical', 'horizontal'], + control: { type: 'inline-radio' }, + table: { defaultValue: { summary: 'vertical' } }, + }, + overScan: { + type: 'number', + description: + 'Number of extra items to render beyond the visible area of the viewport.\nHigher values reduce blank flashes during fast scrolling but may impact performance.', + control: 'number', + table: { defaultValue: { summary: '2' } }, + }, + estimatedItemSize: { + type: 'number', + description: + 'Estimated item size in pixels used before an item is measured in the DOM.\nThe engine replaces this with the actual measured size after the first render of each item.', + control: 'number', + table: { defaultValue: { summary: '50' } }, + }, + }, + args: { orientation: 'vertical', overScan: 2, estimatedItemSize: 50 }, +}; + +export default metadata; + +interface IgcVirtualScrollArgs { + /** Scroll orientation of the virtual scroll. */ + orientation: 'vertical' | 'horizontal'; + /** + * Number of extra items to render beyond the visible area of the viewport. + * Higher values reduce blank flashes during fast scrolling but may impact performance. + */ + overScan: number; + /** + * Estimated item size in pixels used before an item is measured in the DOM. + * The engine replaces this with the actual measured size after the first render of each item. + */ + estimatedItemSize: number; +} +type Story = StoryObj; + +// endregion + +interface Person { + id: number; + name: string; + email: string; + department: string; +} + +const DEPARTMENTS = [ + 'Engineering', + 'Design', + 'Marketing', + 'Sales', + 'HR', + 'Finance', + 'Legal', + 'Operations', +]; + +const FIRST_NAMES = [ + 'Alice', + 'Bob', + 'Carol', + 'David', + 'Eve', + 'Frank', + 'Grace', + 'Henry', + 'Iris', + 'Jack', + 'Karen', + 'Leo', + 'Mia', + 'Noah', + 'Olivia', + 'Paul', +]; + +const LAST_NAMES = [ + 'Smith', + 'Johnson', + 'Williams', + 'Brown', + 'Jones', + 'Garcia', + 'Miller', + 'Davis', + 'Wilson', + 'Moore', + 'Taylor', + 'Anderson', + 'Thomas', + 'Jackson', +]; + +const CHIP_VARIANTS = [ + 'primary', + 'info', + 'success', + 'warning', + 'danger', +] as const; + +type ChipVariant = (typeof CHIP_VARIANTS)[number]; + +function deptVariant(dept: string): ChipVariant { + return CHIP_VARIANTS[DEPARTMENTS.indexOf(dept) % CHIP_VARIANTS.length]; +} + +function initials(name: string): string { + const parts = name.split(' '); + return parts.length >= 2 ? `${parts[0][0]}${parts[1][0]}` : name[0]; +} + +function generatePeople(count: number): Person[] { + return Array.from({ length: count }, (_, i) => { + const first = FIRST_NAMES[i % FIRST_NAMES.length]; + const last = + LAST_NAMES[Math.floor(i / FIRST_NAMES.length) % LAST_NAMES.length]; + const dept = DEPARTMENTS[i % DEPARTMENTS.length]; + return { + id: i + 1, + name: `${first} ${last}`, + email: `${first.toLowerCase()}.${last.toLowerCase()}${i > FIRST_NAMES.length * LAST_NAMES.length ? i : ''}@example.com`, + department: dept, + }; + }); +} + +const people = generatePeople(10_000); + +export const Vertical: Story = { + argTypes: disableStoryControls(metadata), + parameters: { + docs: { + description: { + story: + 'Vertical virtual scroll rendering 10,000 items. Only items within the visible viewport are rendered in the DOM.', + }, + }, + actions: { handles: [] }, + }, + render: (args) => { + const itemTemplate = (ctx: VirtualScrollItemContext) => html` + + + ${ctx.value.name} + ${ctx.value.email} + ${ctx.value.department} + + `; + + return html` + +

Employees (${people.length})

+ } + style="height: 480px;" + > +
+ `; + }, +}; + +export const Horizontal: Story = { + argTypes: disableStoryControls(metadata), + parameters: { + docs: { + description: { + story: 'Horizontal virtual scroll rendering 10,000 cards side by side.', + }, + }, + actions: { handles: [] }, + }, + args: { + orientation: 'horizontal', + estimatedItemSize: 200, + }, + render: (args) => { + const itemTemplate = (ctx: VirtualScrollItemContext) => html` + + + +

${ctx.value.name}

+
#${ctx.value.id}
+
+ + ${ctx.value.department} + +
+ `; + + return html` + } + style="height: 220px;" + > + `; + }, +}; + +export const HorizontalVariableWidth: Story = { + argTypes: disableStoryControls(metadata), + parameters: { + docs: { + description: { + story: + 'Horizontal virtual scroll with variable-width cards. Every third card is wider and includes the email address. The engine measures each rendered item and adjusts its width estimate automatically.', + }, + }, + actions: { handles: [] }, + }, + args: { + orientation: 'horizontal', + estimatedItemSize: 200, + }, + render: (args) => { + const itemTemplate = (ctx: VirtualScrollItemContext) => { + const isWide = ctx.index % 3 === 0; + const width = isWide ? 280 : 190; + + return html` + + + +

${ctx.value.name}

+
+ ${isWide ? ctx.value.email : `#${ctx.value.id}`} +
+
+ + ${ctx.value.department} + +
+ `; + }; + + return html` + } + style="height: 220px;" + > + `; + }, +}; + +export const VariableHeight: Story = { + argTypes: disableStoryControls(metadata), + parameters: { + docs: { + description: { + story: + 'Demonstrates adaptive item measurement: every third item includes an additional bio line, making it taller. The engine measures each rendered item and updates its size estimate automatically.', + }, + }, + actions: { handles: [] }, + }, + render: (args) => { + const longBios = [ + 'Leads a cross-functional team across three time zones.', + 'Specializes in scalable microservices architecture.', + 'Passionate about accessible, pixel-perfect UI.', + 'Drives product strategy and stakeholder alignment.', + 'Champions data-driven decision making.', + ]; + + const itemTemplate = (ctx: VirtualScrollItemContext) => { + const bio = + ctx.index % 3 === 0 ? longBios[ctx.index % longBios.length] : nothing; + + return html` + + + ${ctx.value.name} + ${ctx.value.department} ${bio} + ${ctx.value.department} + + `; + }; + + return html` + +

Employees (${people.length})

+ } + style="height: 480px;" + > +
+ `; + }, +}; + +export const RemoteData: Story = { + argTypes: disableStoryControls(metadata), + parameters: { + docs: { + description: { + story: + 'Demonstrates the `igcDataRequest` event for infinite-scroll / remote data loading. New pages are appended as the user scrolls near the end.', + }, + }, + actions: { handles: [] }, + }, + render: (args) => { + const PAGE_SIZE = 50; + let loading = false; + let items: Person[] = generatePeople(PAGE_SIZE); + + const vs = () => + document.querySelector('#remote-vs'); + + const loadMore = () => { + if (loading) return; + loading = true; + + // Simulate network delay + setTimeout( + () => { + const next = generatePeople(PAGE_SIZE).map((p) => ({ + ...p, + id: items.length + p.id, + name: `${p.name} (page ${Math.floor(items.length / PAGE_SIZE) + 1})`, + })); + items = [...items, ...next]; + const el = vs(); + if (el) { + el.data = items; + } + loading = false; + }, + Math.random() * 1000 + 500 + ); + }; + + const itemTemplate = (ctx: VirtualScrollItemContext) => html` + + + ${ctx.value.name} + ${ctx.value.email} + ${ctx.value.department} + + ${ctx.isLast + ? html`` + : nothing} + `; + + return html` + +

Employees

+ } + @igcDataRequest=${loadMore} + style="height: 480px;" + > +
+ `; + }, +};