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

IgniteUI / igniteui-webcomponents / 28921354167

08 Jul 2026 06:04AM UTC coverage: 98.334% (-0.001%) from 98.335%
28921354167

Pull #2222

github

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

6068 of 6383 branches covered (95.07%)

Branch coverage included in aggregate %.

879 of 887 new or added lines in 6 files covered. (99.1%)

42859 of 43373 relevant lines covered (98.81%)

1556.0 hits per line

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

98.77
/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) {
540✔
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;
540✔
184
  }
540✔
185

9✔
186
  private _adoptStyles(): void {
9✔
187
    const root = this.getRootNode() as Document | ShadowRoot;
540✔
188
    const sheet = IgcVirtualScrollComponent._getStyleSheet();
540✔
189
    if (!root.adoptedStyleSheets.includes(sheet)) {
540✔
190
      root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
112✔
191
    }
112✔
192
  }
540✔
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
    // TODO: Either fix this in the theming controller or come up with some other solution.
413✔
230

413✔
231
    // Re-verified (cheap, idempotent no-op when already present) on every
413✔
232
    // update rather than only in `connectedCallback`. Hosts that render this
413✔
233
    // component into light DOM inside an *ancestor's* shadow root (e.g. combo)
413✔
234
    // may have that shadow root's `adoptedStyleSheets` wholesale replaced by
413✔
235
    // the ancestor's own styling/theming logic, silently dropping this sheet
413✔
236
    // without ever disconnecting/reconnecting this element.
413✔
237
    this._adoptStyles();
413✔
238

413✔
239
    if (changed.has('data') || changed.has('estimatedItemSize')) {
413✔
240
      this._engine.resize(this.data.length, this.estimatedItemSize);
174✔
241
      this._hasPendingDataRequest = false;
174✔
242
    }
174✔
243

413✔
244
    if (changed.has('orientation')) {
413✔
245
      this._measureViewport();
127✔
246
      this._setupScrollListener();
127✔
247
    }
127✔
248
  }
413✔
249

9✔
250
  protected override updated(_changed: PropertyValues<this>): void {
9✔
251
    this._scheduleItemMeasurement();
413✔
252
    this._checkDataRequest();
413✔
253

413✔
254
    const range = this._currentRange;
413✔
255
    if (range.endIndex >= range.startIndex) {
413✔
256
      this.emitEvent('igcStateChange', {
206✔
257
        detail: {
206✔
258
          startIndex: range.startIndex,
206✔
259
          endIndex: range.endIndex,
206✔
260
          viewportSize: this._viewportSize,
206✔
261
          totalSize: this._engine.totalSize,
206✔
262
        },
206✔
263
      });
206✔
264
    }
206✔
265
  }
413✔
266

9✔
267
  protected override render(): TemplateResult {
9✔
268
    if (!this.itemTemplate) {
413✔
269
      return html`${nothing}`;
5✔
270
    }
5✔
271

408✔
272
    this._currentRange = this._engine.getVisibleRange(
408✔
273
      this._scrollPosition,
408✔
274
      this._viewportSize,
408✔
275
      this.overScan,
408✔
276
      this.data.length
408✔
277
    );
408✔
278

408✔
279
    const range = this._currentRange;
408✔
280
    const count = this.data.length;
408✔
281
    const isVertical = this._isVertical;
408✔
282

408✔
283
    const trackStyle = isVertical
408✔
284
      ? { height: `${this._engine.domSize}px` }
396✔
285
      : { width: `${this._engine.domSize}px` };
12✔
286

413✔
287
    let contentPosition = this._engine.getContentPosition(range.startIndex);
413✔
288
    const physicalRangeSize = this._engine.getPhysicalRangeSize(
413✔
289
      range.startIndex,
413✔
290
      range.endIndex
413✔
291
    );
413✔
292
    contentPosition = Math.max(
413✔
293
      0,
413✔
294
      Math.min(contentPosition, this._engine.domSize - physicalRangeSize)
413✔
295
    );
413✔
296
    const isRTL = !isVertical && !isLTR(this);
413✔
297
    const contentStyle = {
413✔
298
      transform: isVertical
413✔
299
        ? `translateY(${contentPosition}px)`
396✔
300
        : `translateX(${isRTL ? -contentPosition : contentPosition}px)`,
12✔
301
    };
413✔
302

413✔
303
    const visibleItems =
413✔
304
      range.endIndex >= range.startIndex
413✔
305
        ? this.data.slice(range.startIndex, range.endIndex + 1)
206✔
306
        : [];
202✔
307

413✔
308
    return html`
413✔
309
      <div part="igc-vs-track" style=${styleMap(trackStyle)} aria-hidden="true">
413✔
310
        <div
413✔
311
          ${ref(this._contentRef)}
413✔
312
          part="igc-vs-content"
413✔
313
          style=${styleMap(contentStyle)}
413✔
314
        >
413✔
315
          ${visibleItems.map((item, i) => {
413✔
316
            const itemIndex = range.startIndex + i;
1,559✔
317
            const ctx = new VirtualScrollItemContext(item, itemIndex, count);
1,559✔
318
            return html`<div data-vs-index=${itemIndex}>
1,559✔
319
              ${this.itemTemplate!(ctx)}
1,559✔
320
            </div>`;
1,559✔
321
          })}
413✔
322
        </div>
413✔
323
      </div>
413✔
324
    `;
413✔
325
  }
413✔
326

9✔
327
  //#endregion
9✔
328

9✔
329
  //#region Internal API
9✔
330

9✔
331
  private get _isVertical(): boolean {
9✔
332
    return this.orientation === 'vertical';
1,610✔
333
  }
1,610✔
334

9✔
335
  private _measureViewport(): void {
9✔
336
    const size = this._isVertical ? this.clientHeight : this.clientWidth;
254✔
337
    if (size !== this._viewportSize) {
254✔
338
      this._viewportSize = size;
16✔
339
    }
16✔
340
  }
254✔
341

9✔
342
  private _setupScrollListener(): void {
9✔
343
    if (this._onScroll) {
254✔
344
      this.removeEventListener('scroll', this._onScroll);
127✔
345
    }
127✔
346

254✔
347
    this._onScroll = (e: Event) => {
254✔
348
      const target = e.target as HTMLElement;
6✔
349
      const scrollPos = this._isVertical
6✔
350
        ? target.scrollTop
2✔
351
        : isLTR(this)
4✔
352
          ? target.scrollLeft
1✔
353
          : -target.scrollLeft;
3✔
354
      this._scrollPosition = scrollPos;
6✔
355
    };
6✔
356

254✔
357
    this.addEventListener('scroll', this._onScroll, { passive: true });
254✔
358
  }
254✔
359

9✔
360
  private _handleViewportResize(): void {
9✔
361
    const newSize = this._isVertical ? this.clientHeight : this.clientWidth;
107✔
362
    if (newSize !== this._viewportSize) {
107✔
363
      this._viewportSize = newSize;
61✔
364
    }
61✔
365
  }
107✔
366

9✔
367
  private _handleItemResize(entries: ResizeObserverEntry[]): void {
9✔
368
    for (const entry of entries) {
106✔
369
      const el = entry.target as HTMLElement;
812✔
370
      const index = asNumber(el.dataset.vsIndex, -1);
812✔
371
      if (index < 0) continue;
812!
372

812✔
373
      const measured = this._isVertical
812✔
374
        ? (entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height)
769!
375
        : (entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width);
43!
376

812✔
377
      if (measured > 0) {
812✔
378
        this._engine.measureItem(index, measured);
812✔
379
      }
812✔
380
    }
812✔
381
  }
106✔
382

9✔
383
  private _scheduleItemMeasurement(): void {
9✔
384
    if (!this._contentRef.value) return;
413✔
385

407✔
386
    if (!this._itemResizeObserver) {
413✔
387
      this._itemResizeObserver = new ResizeObserver(this._handleItemResize);
123✔
388
    }
123✔
389

407✔
390
    this._itemResizeObserver.disconnect();
407✔
391
    for (const el of this._contentRef.value.children) {
413✔
392
      this._itemResizeObserver.observe(el);
1,551✔
393
    }
1,551✔
394
  }
413✔
395

9✔
396
  private _checkDataRequest(): void {
9✔
397
    if (this._hasPendingDataRequest) return;
413✔
398
    const range = this._currentRange;
285✔
399
    const total = this.data.length;
285✔
400

285✔
401
    if (total > 0 && range.endIndex >= total - REMOTE_SCROLLING_THRESHOLD) {
413✔
402
      this._hasPendingDataRequest = true;
78✔
403
      this.emitEvent('igcDataRequest', {
78✔
404
        detail: {
78✔
405
          startIndex: total,
78✔
406
          count: Math.max(this.overScan * 4, 20),
78✔
407
        },
78✔
408
      });
78✔
409
    }
78✔
410
  }
413✔
411

9✔
412
  private _dispose(): void {
9✔
413
    if (this._onScroll) {
127✔
414
      this.removeEventListener('scroll', this._onScroll);
127✔
415
      this._onScroll = null;
127✔
416
    }
127✔
417
    this._itemResizeObserver?.disconnect();
127✔
418
    this._itemResizeObserver = null;
127✔
419
  }
127✔
420

9✔
421
  private _nextFrame(): Promise<void> {
9✔
422
    return new Promise((resolve) => requestAnimationFrame(() => resolve()));
68✔
423
  }
68✔
424

9✔
425
  /**
9✔
426
   * Waits for the current update to finish and then gives any
9✔
427
   * ResizeObserver-driven item measurements a chance to run. If those
9✔
428
   * measurements schedule a follow-up render (e.g. because an estimated
9✔
429
   * item size was replaced with its real, measured size), the wait is
9✔
430
   * repeated until no further renders are pending, up to a safety cap.
9✔
431
   */
9✔
432
  private async _resolveLayoutComplete(): Promise<void> {
9✔
433
    await this.updateComplete;
68✔
434

68✔
435
    for (let i = 0; i < MAX_LAYOUT_SETTLE_PASSES; i++) {
68✔
436
      await this._nextFrame();
68✔
437

68✔
438
      if (!this.isUpdatePending) {
68✔
439
        break;
68✔
440
      }
68!
NEW
441

×
NEW
442
      await this.updateComplete;
×
NEW
443
    }
×
444

68✔
445
    this._layoutCompletePromise = null;
68✔
446
  }
68✔
447

9✔
448
  //#endregion
9✔
449

9✔
450
  //#region Public API
9✔
451

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

9✔
471
  /** Programmatically scrolls to the specified item index. */
9✔
472
  public scrollToIndex(index: number, options?: ScrollIntoViewOptions): void {
9✔
473
    const offset = this._engine.getScrollOffsetForIndex(index);
23✔
474
    const opts = options ?? {};
23✔
475

23✔
476
    if (this._isVertical) {
23✔
477
      this.scrollTo({ top: offset, ...opts });
21✔
478
    } else {
23✔
479
      this.scrollTo({ left: isLTR(this) ? offset : -offset, ...opts });
2✔
480
    }
2✔
481
  }
23✔
482

9✔
483
  //#endregion
9✔
484
}
9✔
485

9✔
486
declare global {
9✔
487
  interface HTMLElementTagNameMap {
9✔
488
    'igc-virtual-scroll': IgcVirtualScrollComponent;
9✔
489
  }
9✔
490
}
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