• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

IgniteUI / igniteui-webcomponents / 28851216392

07 Jul 2026 08:05AM UTC coverage: 98.329% (-0.006%) from 98.335%
28851216392

Pull #2222

github

web-flow
Merge e23869691 into fcab913eb
Pull Request #2222: feat: Added virtualization component

6063 of 6378 branches covered (95.06%)

Branch coverage included in aggregate %.

854 of 864 new or added lines in 6 files covered. (98.84%)

8 existing lines in 1 file now uncovered.

42835 of 43351 relevant lines covered (98.81%)

1556.78 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.38
/src/components/virtualization/virtualization.ts
1
import {
9✔
2
  html,
9✔
3
  LitElement,
9✔
4
  nothing,
9✔
5
  type PropertyValues,
9✔
6
  type TemplateResult,
9✔
7
} from 'lit';
9✔
8
import { property, state } from 'lit/decorators.js';
9✔
9
import { createRef, ref } from 'lit/directives/ref.js';
9✔
10
import { styleMap } from 'lit/directives/style-map.js';
9✔
11
import { createResizeObserverController } from '../common/controllers/resize-observer.js';
9✔
12
import { registerComponent } from '../common/definitions/register.js';
9✔
13
import type { Constructor } from '../common/mixins/constructor.js';
9✔
14
import { EventEmitterMixin } from '../common/mixins/event-emitter.js';
9✔
15
import { asNumber, isLTR } from '../common/util.js';
9✔
16
import { VirtualScrollEngine, type VisibleRange } from './engine.js';
9✔
17
import {
9✔
18
  type VirtualScrollDataRequest,
9✔
19
  VirtualScrollItemContext,
9✔
20
  type VirtualScrollState,
9✔
21
} from './types.js';
9✔
22

9✔
23
export type VirtualScrollItemTemplate<T> = (
9✔
24
  context: VirtualScrollItemContext<T>
9✔
25
) => TemplateResult | typeof nothing;
9✔
26

9✔
27
export interface IgcVirtualScrollComponentEventMap {
9✔
28
  igcStateChange: CustomEvent<VirtualScrollState>;
9✔
29
  igcDataRequest: CustomEvent<VirtualScrollDataRequest>;
9✔
30
}
9✔
31

9✔
32
const REMOTE_SCROLLING_THRESHOLD = 5;
9✔
33
const MAX_LAYOUT_SETTLE_PASSES = 20;
9✔
34

9✔
35
/**
9✔
36
 * A virtual scroll component that efficiently renders large lists by only
9✔
37
 * rendering the items currently visible in the viewport.
9✔
38
 *
9✔
39
 * @element igc-virtual-scroll
9✔
40
 *
9✔
41
 * @fires igcStateChange - Emitted after each render pass with a snapshot of the current virtual window.
9✔
42
 * @fires igcDataRequest - Emitted when the scroll position approaches the end of the available data.
9✔
43
 */
9✔
44
export default class IgcVirtualScrollComponent<
9✔
45
  T = unknown,
9✔
46
> extends EventEmitterMixin<
9✔
47
  IgcVirtualScrollComponentEventMap,
9✔
48
  Constructor<LitElement>
9✔
49
>(LitElement) {
9✔
50
  public static readonly tagName = 'igc-virtual-scroll';
9✔
51

9✔
52
  /* blazorSuppress */
9✔
53
  public static register(): void {
9✔
54
    registerComponent(IgcVirtualScrollComponent);
2✔
55
  }
2✔
56

9✔
57
  //#region Internal state
9✔
58

9✔
59
  protected readonly _engine = new VirtualScrollEngine();
9✔
60

9✔
61
  private readonly _contentRef = createRef<HTMLDivElement>();
9✔
62
  private _itemResizeObserver: ResizeObserver | null = null;
9✔
63
  private _onScroll: ((e: Event) => void) | null = null;
9✔
64
  private _currentRange: VisibleRange = { startIndex: 0, endIndex: -1 };
9✔
65
  private _hasPendingDataRequest = false;
9✔
66
  private _layoutCompletePromise: Promise<void> | null = null;
9✔
67

9✔
68
  @state()
9✔
69
  private _scrollPosition = 0;
9✔
70

9✔
71
  @state()
9✔
72
  private _viewportSize = 0;
9✔
73

9✔
74
  //#endregion
9✔
75

9✔
76
  //#region Public properties
9✔
77

9✔
78
  /**
9✔
79
   * The array of items to virtualize.
9✔
80
   */
9✔
81
  @property({ attribute: false })
9✔
82
  public data: T[] = [];
9✔
83

9✔
84
  /**
9✔
85
   * Scroll orientation of the virtual scroll.
9✔
86
   * @attr orientation
9✔
87
   * @default 'vertical'
9✔
88
   */
9✔
89
  @property({ reflect: true })
9✔
90
  public orientation: 'vertical' | 'horizontal' = 'vertical';
9✔
91

9✔
92
  /**
9✔
93
   * Number of extra items to render beyond the visible area of the viewport.
9✔
94
   * Higher values reduce blank flashes during fast scrolling but may impact performance.
9✔
95
   * @attr over-scan
9✔
96
   * @default 2
9✔
97
   */
9✔
98
  @property({ type: Number, attribute: 'over-scan' })
9✔
99
  public overScan = 2;
9✔
100

9✔
101
  /**
9✔
102
   * Estimated item size in pixels used before an item is measured in the DOM.
9✔
103
   * The engine replaces this with the actual measured size after the first render of each item.
9✔
104
   * @attr estimated-item-size
9✔
105
   * @default 50
9✔
106
   */
9✔
107
  @property({ type: Number, attribute: 'estimated-item-size' })
9✔
108
  public estimatedItemSize = 50;
9✔
109

9✔
110
  /**
9✔
111
   * A function that renders each item in the virtual scroll list.
9✔
112
   * Receives a VirtualScrollItemContext<T> with the item data, its index, and the total count.
9✔
113
   * If not provided, nothing is rendered.
9✔
114
   */
9✔
115
  @property({ attribute: false })
9✔
116
  public itemTemplate: VirtualScrollItemTemplate<T> | null = null;
9✔
117

9✔
118
  //#endregion
9✔
119

9✔
120
  private static _styleSheet: CSSStyleSheet | null = null;
9✔
121

9✔
122
  private static _getStyleSheet(): CSSStyleSheet {
9✔
123
    if (!IgcVirtualScrollComponent._styleSheet) {
127✔
124
      const sheet = new CSSStyleSheet();
2✔
125
      sheet.replaceSync(`
2✔
126
        igc-virtual-scroll {
2✔
127
          display: block;
2✔
128
          position: relative;
2✔
129
          overflow: auto;
2✔
130
          height: 18.75rem;
2✔
131
        }
2✔
132

2✔
133
        igc-virtual-scroll[orientation='vertical'] {
2✔
134
          overflow-y: auto;
2✔
135
          overflow-x: hidden;
2✔
136
        }
2✔
137

2✔
138
        igc-virtual-scroll[orientation='horizontal'] {
2✔
139
          overflow-x: auto;
2✔
140
          overflow-y: hidden;
2✔
141
        }
2✔
142

2✔
143
        [part="igc-vs-track"] {
2✔
144
          position: relative;
2✔
145
          width: 100%;
2✔
146
          min-height: 100%;
2✔
147
        }
2✔
148

2✔
149
        [part="igc-vs-content"] {
2✔
150
          position: absolute;
2✔
151
          top: 0;
2✔
152
          left: 0;
2✔
153
          width: 100%;
2✔
154
          will-change: transform;
2✔
155
          contain: layout style paint;
2✔
156
        }
2✔
157

2✔
158
        igc-virtual-scroll[orientation='horizontal'] [part="igc-vs-track"] {
2✔
159
          height: 100%;
2✔
160
          width: auto;
2✔
161
          min-height: unset;
2✔
162
        }
2✔
163

2✔
164
        igc-virtual-scroll[orientation='horizontal'] [part="igc-vs-content"] {
2✔
165
          display: flex;
2✔
166
          flex-direction: row;
2✔
167
          height: 100%;
2✔
168
          width: auto;
2✔
169
        }
2✔
170

2✔
171
        igc-virtual-scroll[orientation='horizontal'] [part="igc-vs-content"] > [data-vs-index] {
2✔
172
          flex-shrink: 0;
2✔
173
          height: 100%;
2✔
174
        }
2✔
175

2✔
176
        igc-virtual-scroll[orientation='horizontal']:dir(rtl) [part="igc-vs-content"] {
2✔
177
          left: auto;
2✔
178
          right: 0;
2✔
179
        }
2✔
180
      `);
2✔
181
      IgcVirtualScrollComponent._styleSheet = sheet;
2✔
182
    }
2✔
183
    return IgcVirtualScrollComponent._styleSheet;
127✔
184
  }
127✔
185

9✔
186
  private _adoptStyles(): void {
9✔
187
    const root = this.getRootNode() as Document | ShadowRoot;
127✔
188
    const sheet = IgcVirtualScrollComponent._getStyleSheet();
127✔
189
    if (!root.adoptedStyleSheets.includes(sheet)) {
127✔
190
      root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
112✔
191
    }
112✔
192
  }
127✔
193

9✔
194
  constructor() {
9✔
195
    super();
127✔
196
    this._engine.onSizeChange = () => this.requestUpdate();
127✔
197
    this._handleItemResize = this._handleItemResize.bind(this);
127✔
198
    this._handleViewportResize = this._handleViewportResize.bind(this);
127✔
199

127✔
200
    // Viewport resize observer
127✔
201
    createResizeObserverController(this, {
127✔
202
      callback: this._handleViewportResize,
127✔
203
    });
127✔
204
  }
127✔
205

9✔
206
  //#region Lit lifecycle
9✔
207

9✔
208
  /** @internal */
9✔
209
  public override createRenderRoot(): HTMLElement | DocumentFragment {
9✔
210
    return this;
127✔
211
  }
127✔
212

9✔
213
  /** @internal */
9✔
214
  public override connectedCallback(): void {
9✔
215
    super.connectedCallback();
127✔
216
    this._adoptStyles();
127✔
217
    this._engine.initMaxBrowserSize(document);
127✔
218
    this._measureViewport();
127✔
219
    this._setupScrollListener();
127✔
220
  }
127✔
221

9✔
222
  /** @internal */
9✔
223
  public override disconnectedCallback(): void {
9✔
224
    super.disconnectedCallback();
127✔
225
    this._dispose();
127✔
226
  }
127✔
227

9✔
228
  protected override willUpdate(changed: PropertyValues<this>): void {
9✔
229
    if (changed.has('data') || changed.has('estimatedItemSize')) {
420✔
230
      this._engine.resize(this.data.length, this.estimatedItemSize);
174✔
231
      this._hasPendingDataRequest = false;
174✔
232
    }
174✔
233

420✔
234
    if (changed.has('orientation')) {
420✔
235
      this._measureViewport();
127✔
236
      this._setupScrollListener();
127✔
237
    }
127✔
238
  }
420✔
239

9✔
240
  protected override updated(_changed: PropertyValues<this>): void {
9✔
241
    this._scheduleItemMeasurement();
420✔
242
    this._checkDataRequest();
420✔
243

420✔
244
    const range = this._currentRange;
420✔
245
    if (range.endIndex >= range.startIndex) {
420✔
246
      this.emitEvent('igcStateChange', {
213✔
247
        detail: {
213✔
248
          startIndex: range.startIndex,
213✔
249
          endIndex: range.endIndex,
213✔
250
          viewportSize: this._viewportSize,
213✔
251
          totalSize: this._engine.totalSize,
213✔
252
        },
213✔
253
      });
213✔
254
    }
213✔
255
  }
420✔
256

9✔
257
  protected override render(): TemplateResult {
9✔
258
    if (!this.itemTemplate) {
420✔
259
      return html`${nothing}`;
5✔
260
    }
5✔
261

415✔
262
    this._currentRange = this._engine.getVisibleRange(
415✔
263
      this._scrollPosition,
415✔
264
      this._viewportSize,
415✔
265
      this.overScan,
415✔
266
      this.data.length
415✔
267
    );
415✔
268

415✔
269
    const range = this._currentRange;
415✔
270
    const count = this.data.length;
415✔
271
    const isVertical = this._isVertical;
415✔
272

415✔
273
    const trackStyle = isVertical
415✔
274
      ? { height: `${this._engine.domSize}px` }
404✔
275
      : { width: `${this._engine.domSize}px` };
11✔
276

420✔
277
    let contentPosition = this._engine.getContentPosition(range.startIndex);
420✔
278
    const physicalRangeSize = this._engine.getPhysicalRangeSize(
420✔
279
      range.startIndex,
420✔
280
      range.endIndex
420✔
281
    );
420✔
282
    contentPosition = Math.max(
420✔
283
      0,
420✔
284
      Math.min(contentPosition, this._engine.domSize - physicalRangeSize)
420✔
285
    );
420✔
286
    const isRTL = !isVertical && !isLTR(this);
420✔
287
    const contentStyle = {
420✔
288
      transform: isVertical
420✔
289
        ? `translateY(${contentPosition}px)`
404✔
290
        : `translateX(${isRTL ? -contentPosition : contentPosition}px)`,
11✔
291
    };
420✔
292

420✔
293
    const visibleItems =
420✔
294
      range.endIndex >= range.startIndex
420✔
295
        ? this.data.slice(range.startIndex, range.endIndex + 1)
213✔
296
        : [];
202✔
297

420✔
298
    return html`
420✔
299
      <div part="igc-vs-track" style=${styleMap(trackStyle)} aria-hidden="true">
420✔
300
        <div
420✔
301
          ${ref(this._contentRef)}
420✔
302
          part="igc-vs-content"
420✔
303
          style=${styleMap(contentStyle)}
420✔
304
        >
420✔
305
          ${visibleItems.map((item, i) => {
420✔
306
            const itemIndex = range.startIndex + i;
1,606✔
307
            const ctx = new VirtualScrollItemContext(item, itemIndex, count);
1,606✔
308
            return html`<div data-vs-index=${itemIndex}>
1,606✔
309
              ${this.itemTemplate!(ctx)}
1,606✔
310
            </div>`;
1,606✔
311
          })}
420✔
312
        </div>
420✔
313
      </div>
420✔
314
    `;
420✔
315
  }
420✔
316

9✔
317
  //#endregion
9✔
318

9✔
319
  //#region Internal API
9✔
320

9✔
321
  private get _isVertical(): boolean {
9✔
322
    return this.orientation === 'vertical';
1,626✔
323
  }
1,626✔
324

9✔
325
  private _measureViewport(): void {
9✔
326
    const size = this._isVertical ? this.clientHeight : this.clientWidth;
254✔
327
    if (size !== this._viewportSize) {
254✔
328
      this._viewportSize = size;
16✔
329
    }
16✔
330
  }
254✔
331

9✔
332
  private _setupScrollListener(): void {
9✔
333
    if (this._onScroll) {
254✔
334
      this.removeEventListener('scroll', this._onScroll);
127✔
335
    }
127✔
336

254✔
337
    this._onScroll = (e: Event) => {
254✔
338
      const target = e.target as HTMLElement;
3✔
339
      const scrollPos = this._isVertical
3✔
NEW
340
        ? target.scrollTop
×
341
        : isLTR(this)
3✔
NEW
342
          ? target.scrollLeft
×
343
          : -target.scrollLeft;
3✔
344
      this._scrollPosition = scrollPos;
3✔
345
    };
3✔
346

254✔
347
    this.addEventListener('scroll', this._onScroll, { passive: true });
254✔
348
  }
254✔
349

9✔
350
  private _handleViewportResize(): void {
9✔
351
    const newSize = this._isVertical ? this.clientHeight : this.clientWidth;
116✔
352
    if (newSize !== this._viewportSize) {
116✔
353
      this._viewportSize = newSize;
62✔
354
    }
62✔
355
  }
116✔
356

9✔
357
  private _handleItemResize(entries: ResizeObserverEntry[]): void {
9✔
358
    for (const entry of entries) {
109✔
359
      const el = entry.target as HTMLElement;
815✔
360
      const index = asNumber(el.dataset.vsIndex, -1);
815✔
361
      if (index < 0) continue;
815!
362

815✔
363
      const measured = this._isVertical
815✔
364
        ? (entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height)
792!
365
        : (entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width);
23!
366

815✔
367
      if (measured > 0) {
815✔
368
        this._engine.measureItem(index, measured);
815✔
369
      }
815✔
370
    }
815✔
371
  }
109✔
372

9✔
373
  private _scheduleItemMeasurement(): void {
9✔
374
    if (!this._contentRef.value) return;
420✔
375

414✔
376
    if (!this._itemResizeObserver) {
420✔
377
      this._itemResizeObserver = new ResizeObserver(this._handleItemResize);
123✔
378
    }
123✔
379

414✔
380
    this._itemResizeObserver.disconnect();
414✔
381
    for (const el of this._contentRef.value.children) {
420✔
382
      this._itemResizeObserver.observe(el);
1,598✔
383
    }
1,598✔
384
  }
420✔
385

9✔
386
  private _checkDataRequest(): void {
9✔
387
    if (this._hasPendingDataRequest) return;
420✔
388
    const range = this._currentRange;
285✔
389
    const total = this.data.length;
285✔
390

285✔
391
    if (total > 0 && range.endIndex >= total - REMOTE_SCROLLING_THRESHOLD) {
420✔
392
      this._hasPendingDataRequest = true;
80✔
393
      this.emitEvent('igcDataRequest', {
80✔
394
        detail: {
80✔
395
          startIndex: total,
80✔
396
          count: Math.max(this.overScan * 4, 20),
80✔
397
        },
80✔
398
      });
80✔
399
    }
80✔
400
  }
420✔
401

9✔
402
  private _dispose(): void {
9✔
403
    if (this._onScroll) {
127✔
404
      this.removeEventListener('scroll', this._onScroll);
127✔
405
      this._onScroll = null;
127✔
406
    }
127✔
407
    this._itemResizeObserver?.disconnect();
127✔
408
    this._itemResizeObserver = null;
127✔
409
  }
127✔
410

9✔
411
  private _nextFrame(): Promise<void> {
9✔
412
    return new Promise((resolve) => requestAnimationFrame(() => resolve()));
68✔
413
  }
68✔
414

9✔
415
  /**
9✔
416
   * Waits for the current update to finish and then gives any
9✔
417
   * ResizeObserver-driven item measurements a chance to run. If those
9✔
418
   * measurements schedule a follow-up render (e.g. because an estimated
9✔
419
   * item size was replaced with its real, measured size), the wait is
9✔
420
   * repeated until no further renders are pending, up to a safety cap.
9✔
421
   */
9✔
422
  private async _resolveLayoutComplete(): Promise<void> {
9✔
423
    await this.updateComplete;
68✔
424

68✔
425
    for (let i = 0; i < MAX_LAYOUT_SETTLE_PASSES; i++) {
68✔
426
      await this._nextFrame();
68✔
427

68✔
428
      if (!this.isUpdatePending) {
68✔
429
        break;
68✔
430
      }
68!
NEW
431

×
NEW
432
      await this.updateComplete;
×
NEW
433
    }
×
434

68✔
435
    this._layoutCompletePromise = null;
68✔
436
  }
68✔
437

9✔
438
  //#endregion
9✔
439

9✔
440
  //#region Public API
9✔
441

9✔
442
  /* blazorSuppress */
9✔
443
  /**
9✔
444
   * A promise that resolves once the virtual scroll has fully settled:
9✔
445
   * the current render pass has completed *and* any item-size
9✔
446
   * measurements it triggers (and the renders those in turn schedule)
9✔
447
   * have also completed.
9✔
448
   *
9✔
449
   * Unlike `updateComplete`, which only reflects a single Lit render
9✔
450
   * pass, `layoutComplete` is useful after changing `data`, scrolling,
9✔
451
   * or resizing the viewport, when the final, stable DOM state may only
9✔
452
   * be reached after one or more follow-up renders.
9✔
453
   */
9✔
454
  public get layoutComplete(): Promise<void> {
9✔
455
    if (!this._layoutCompletePromise) {
68✔
456
      this._layoutCompletePromise = this._resolveLayoutComplete();
68✔
457
    }
68✔
458
    return this._layoutCompletePromise;
68✔
459
  }
68✔
460

9✔
461
  /** Programmatically scrolls to the specified item index. */
9✔
462
  public scrollToIndex(index: number, options?: ScrollIntoViewOptions): void {
9✔
463
    const offset = this._engine.getScrollOffsetForIndex(index);
23✔
464
    const opts = options ?? {};
23✔
465

23✔
466
    if (this._isVertical) {
23✔
467
      this.scrollTo({ top: offset, ...opts });
21✔
468
    } else {
23✔
469
      this.scrollTo({ left: isLTR(this) ? offset : -offset, ...opts });
2✔
470
    }
2✔
471
  }
23✔
472

9✔
473
  //#endregion
9✔
474
}
9✔
475

9✔
476
declare global {
9✔
477
  interface HTMLElementTagNameMap {
9✔
478
    'igc-virtual-scroll': IgcVirtualScrollComponent;
9✔
479
  }
9✔
480
}
9✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc