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

IgniteUI / igniteui-webcomponents / 29898063965

22 Jul 2026 06:50AM UTC coverage: 98.318% (-0.01%) from 98.332%
29898063965

Pull #2222

github

web-flow
Merge 082136c07 into 20331d724
Pull Request #2222: feat: Added virtualization component

6107 of 6428 branches covered (95.01%)

Branch coverage included in aggregate %.

987 of 999 new or added lines in 6 files covered. (98.8%)

24 existing lines in 1 file now uncovered.

43157 of 43679 relevant lines covered (98.8%)

1546.35 hits per line

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

97.74
/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
const MAX_SCROLL_CORRECTION_PASSES = 5;
9✔
35
const MAX_SCROLL_SETTLE_PASSES = 180;
9✔
36
const SCROLL_CORRECTION_EPSILON = 1;
9✔
37

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

9✔
55
  /* blazorSuppress */
9✔
56
  public static register(): void {
9✔
57
    registerComponent(IgcVirtualScrollComponent);
2✔
58
  }
2✔
59

9✔
60
  //#region Internal state
9✔
61

9✔
62
  protected readonly _engine = new VirtualScrollEngine();
9✔
63

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

9✔
72
  @state()
9✔
73
  private _scrollPosition = 0;
9✔
74

9✔
75
  @state()
9✔
76
  private _viewportSize = 0;
9✔
77

9✔
78
  //#endregion
9✔
79

9✔
80
  //#region Public properties
9✔
81

9✔
82
  /**
9✔
83
   * The array of items to virtualize.
9✔
84
   */
9✔
85
  @property({ attribute: false })
9✔
86
  public data: T[] = [];
9✔
87

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

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

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

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

9✔
122
  //#endregion
9✔
123

9✔
124
  private static _styleSheet: CSSStyleSheet | null = null;
9✔
125

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

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

2✔
142
        igc-virtual-scroll[orientation='horizontal'] {
2✔
143
          overflow-x: auto;
2✔
144
          overflow-y: hidden;
2✔
145
        }
2✔
146

2✔
147
        [part="igc-vs-track"] {
2✔
148
          position: relative;
2✔
149
          width: 100%;
2✔
150
          min-height: 100%;
2✔
151
        }
2✔
152

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

2✔
162
        igc-virtual-scroll[orientation='horizontal'] [part="igc-vs-track"] {
2✔
163
          height: 100%;
2✔
164
          width: auto;
2✔
165
          min-height: unset;
2✔
166
        }
2✔
167

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

2✔
175
        igc-virtual-scroll[orientation='horizontal'] [part="igc-vs-content"] > [data-vs-index] {
2✔
176
          flex-shrink: 0;
2✔
177
          height: 100%;
2✔
178
        }
2✔
179

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

9✔
190
  private _adoptStyles(): void {
9✔
191
    const root = this.getRootNode() as Document | ShadowRoot;
559✔
192
    const sheet = IgcVirtualScrollComponent._getStyleSheet();
559✔
193
    if (!root.adoptedStyleSheets.includes(sheet)) {
559✔
194
      root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
112✔
195
    }
112✔
196
  }
559✔
197

9✔
198
  constructor() {
9✔
199
    super();
129✔
200
    this._engine.onSizeChange = () => this.requestUpdate();
129✔
201
    this._handleItemResize = this._handleItemResize.bind(this);
129✔
202
    this._handleViewportResize = this._handleViewportResize.bind(this);
129✔
203

129✔
204
    // Viewport resize observer
129✔
205
    createResizeObserverController(this, {
129✔
206
      callback: this._handleViewportResize,
129✔
207
    });
129✔
208
  }
129✔
209

9✔
210
  //#region Lit lifecycle
9✔
211

9✔
212
  /** @internal */
9✔
213
  public override createRenderRoot(): HTMLElement | DocumentFragment {
9✔
214
    return this;
129✔
215
  }
129✔
216

9✔
217
  /** @internal */
9✔
218
  public override connectedCallback(): void {
9✔
219
    super.connectedCallback();
129✔
220
    this._adoptStyles();
129✔
221
    this._engine.initMaxBrowserSize(document);
129✔
222
    this._measureViewport();
129✔
223
    this._setupScrollListener();
129✔
224
  }
129✔
225

9✔
226
  /** @internal */
9✔
227
  public override disconnectedCallback(): void {
9✔
228
    super.disconnectedCallback();
129✔
229
    this._dispose();
129✔
230
  }
129✔
231

9✔
232
  protected override willUpdate(changed: PropertyValues<this>): void {
9✔
233
    // TODO: Either fix this in the theming controller or come up with some other solution.
430✔
234

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

430✔
243
    if (changed.has('data') || changed.has('estimatedItemSize')) {
430✔
244
      this._engine.resize(this.data.length, this.estimatedItemSize);
176✔
245
      this._hasPendingDataRequest = false;
176✔
246
    }
176✔
247

430✔
248
    if (changed.has('orientation')) {
430✔
249
      this._measureViewport();
129✔
250
      this._setupScrollListener();
129✔
251
    }
129✔
252
  }
430✔
253

9✔
254
  protected override updated(_changed: PropertyValues<this>): void {
9✔
255
    this._scheduleItemMeasurement();
430✔
256
    this._checkDataRequest();
430✔
257

430✔
258
    const range = this._currentRange;
430✔
259
    if (range.endIndex >= range.startIndex) {
430✔
260
      this.emitEvent('igcStateChange', {
223✔
261
        detail: {
223✔
262
          startIndex: range.startIndex,
223✔
263
          endIndex: range.endIndex,
223✔
264
          viewportSize: this._viewportSize,
223✔
265
          totalSize: this._engine.totalSize,
223✔
266
        },
223✔
267
      });
223✔
268
    }
223✔
269
  }
430✔
270

9✔
271
  protected override render(): TemplateResult {
9✔
272
    if (!this.itemTemplate) {
430✔
273
      return html`${nothing}`;
5✔
274
    }
5✔
275

425✔
276
    this._currentRange = this._engine.getVisibleRange(
425✔
277
      this._scrollPosition,
425✔
278
      this._viewportSize,
425✔
279
      this.overScan,
425✔
280
      this.data.length
425✔
281
    );
425✔
282

425✔
283
    const range = this._currentRange;
425✔
284
    const count = this.data.length;
425✔
285
    const isVertical = this._isVertical;
425✔
286

425✔
287
    const trackStyle = isVertical
425✔
288
      ? { height: `${this._engine.domSize}px` }
416✔
289
      : { width: `${this._engine.domSize}px` };
9✔
290

430✔
291
    let contentPosition = this._engine.getContentPosition(range.startIndex);
430✔
292
    const physicalRangeSize = this._engine.getPhysicalRangeSize(
430✔
293
      range.startIndex,
430✔
294
      range.endIndex
430✔
295
    );
430✔
296
    contentPosition = Math.max(
430✔
297
      0,
430✔
298
      Math.min(contentPosition, this._engine.domSize - physicalRangeSize)
430✔
299
    );
430✔
300
    const isRTL = !isVertical && !isLTR(this);
430✔
301
    const contentStyle = {
430✔
302
      transform: isVertical
430✔
303
        ? `translateY(${contentPosition}px)`
416✔
304
        : `translateX(${isRTL ? -contentPosition : contentPosition}px)`,
9✔
305
    };
430✔
306

430✔
307
    const visibleItems =
430✔
308
      range.endIndex >= range.startIndex
430✔
309
        ? this.data.slice(range.startIndex, range.endIndex + 1)
223✔
310
        : [];
202✔
311

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

9✔
331
  //#endregion
9✔
332

9✔
333
  //#region Internal API
9✔
334

9✔
335
  private get _isVertical(): boolean {
9✔
336
    return this.orientation === 'vertical';
1,889✔
337
  }
1,889✔
338

9✔
339
  /**
9✔
340
   * Computes the scroll offset that aligns the given item index within the
9✔
341
   * viewport according to `options`, using the engine's *current* size
9✔
342
   * data. As more items get measured, calling this again for the same
9✔
343
   * index/options can yield a different, more accurate result.
9✔
344
   */
9✔
345
  private _getAlignedScrollOffset(
9✔
346
    index: number,
51✔
347
    options?: ScrollIntoViewOptions
51✔
348
  ): number {
51✔
349
    const itemStart = this._engine.getScrollOffsetForIndex(index);
51✔
350
    const itemEnd = this._engine.getScrollOffsetForIndex(index + 1);
51✔
351
    const itemSize = Math.max(0, itemEnd - itemStart);
51✔
352

51✔
353
    const align = this._isVertical
51✔
354
      ? (options?.block ?? 'start')
48✔
355
      : (options?.inline ?? options?.block ?? 'start');
3!
356

51✔
357
    let offset = itemStart;
51✔
358
    if (align === 'center') {
51✔
359
      offset = itemStart - (this._viewportSize - itemSize) / 2;
38✔
360
    } else if (align === 'end') {
51!
NEW
361
      offset = itemStart - (this._viewportSize - itemSize);
×
NEW
362
    }
×
363

51✔
364
    return Math.max(0, offset);
51✔
365
  }
51✔
366

9✔
367
  /** Applies a scroll offset to the correct axis, accounting for RTL. */
9✔
368
  private _applyScroll(offset: number, behavior: ScrollBehavior): void {
9✔
369
    if (this._isVertical) {
34✔
370
      this.scrollTo({ top: offset, behavior });
32✔
371
    } else {
34✔
372
      this.scrollTo({ left: isLTR(this) ? offset : -offset, behavior });
2✔
373
    }
2✔
374
  }
34✔
375

9✔
376
  /** The current real scroll position on the active axis, normalized for RTL. */
9✔
377
  private _currentAxisScroll(): number {
9✔
378
    return this._isVertical
71✔
379
      ? this.scrollTop
66✔
380
      : isLTR(this)
5✔
381
        ? this.scrollLeft
3✔
382
        : -this.scrollLeft;
2✔
383
  }
71✔
384

9✔
385
  /**
9✔
386
   * Waits until the real scroll position on the active axis stops moving
9✔
387
   * between two consecutive frames.
9✔
388
   *
9✔
389
   * `behavior: 'smooth'` scrolls animate asynchronously over multiple
9✔
390
   * frames, so the DOM (and the items rendered around it) only reflect the
9✔
391
   * final position once that animation finishes. Measuring items - and
9✔
392
   * correcting the target offset from those measurements - before that
9✔
393
   * happens would use data from wherever the animation currently happens
9✔
394
   * to be, not from the requested destination.
9✔
395
   */
9✔
396
  private async _waitForScrollSettled(): Promise<void> {
9✔
397
    let previous = this._currentAxisScroll();
34✔
398

34✔
399
    for (let i = 0; i < MAX_SCROLL_SETTLE_PASSES; i++) {
34✔
400
      await this._nextFrame();
38✔
401

37✔
402
      const current = this._currentAxisScroll();
37✔
403
      if (Math.abs(current - previous) < SCROLL_CORRECTION_EPSILON) {
38✔
404
        return;
33✔
405
      }
33✔
406
      previous = current;
4✔
407
    }
4!
408
  }
34✔
409

9✔
410
  private _measureViewport(): void {
9✔
411
    const size = this._isVertical ? this.clientHeight : this.clientWidth;
258✔
412
    if (size !== this._viewportSize) {
258✔
413
      this._viewportSize = size;
18✔
414
    }
18✔
415
  }
258✔
416

9✔
417
  private _setupScrollListener(): void {
9✔
418
    if (this._onScroll) {
258✔
419
      this.removeEventListener('scroll', this._onScroll);
129✔
420
    }
129✔
421

258✔
422
    this._onScroll = (e: Event) => {
258✔
423
      const target = e.target as HTMLElement;
8✔
424
      const scrollPos = this._isVertical
8✔
425
        ? target.scrollTop
6✔
426
        : isLTR(this)
2✔
NEW
427
          ? target.scrollLeft
×
428
          : -target.scrollLeft;
2✔
429
      this._scrollPosition = scrollPos;
8✔
430
    };
8✔
431

258✔
432
    this.addEventListener('scroll', this._onScroll, { passive: true });
258✔
433
  }
258✔
434

9✔
435
  private _handleViewportResize(): void {
9✔
436
    const newSize = this._isVertical ? this.clientHeight : this.clientWidth;
112!
437
    if (newSize !== this._viewportSize) {
112✔
438
      this._viewportSize = newSize;
62✔
439
    }
62✔
440
  }
112✔
441

9✔
442
  private _handleItemResize(entries: ResizeObserverEntry[]): void {
9✔
443
    for (const entry of entries) {
115✔
444
      const el = entry.target as HTMLElement;
930✔
445
      const index = asNumber(el.dataset.vsIndex, -1);
930✔
446
      if (index < 0) continue;
930!
447

930✔
448
      const measured = this._isVertical
930✔
449
        ? (entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height)
930!
NEW
450
        : (entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width);
×
451

930✔
452
      if (measured > 0) {
930✔
453
        this._engine.measureItem(index, measured);
930✔
454
      }
930✔
455
    }
930✔
456
  }
115✔
457

9✔
458
  private _scheduleItemMeasurement(): void {
9✔
459
    if (!this._contentRef.value) return;
430✔
460

424✔
461
    if (!this._itemResizeObserver) {
430✔
462
      this._itemResizeObserver = new ResizeObserver(this._handleItemResize);
125✔
463
    }
125✔
464

424✔
465
    this._itemResizeObserver.disconnect();
424✔
466
    for (const el of this._contentRef.value.children) {
430✔
467
      this._itemResizeObserver.observe(el);
1,776✔
468
    }
1,776✔
469
  }
430✔
470

9✔
471
  private _checkDataRequest(): void {
9✔
472
    if (this._hasPendingDataRequest) return;
430✔
473
    const range = this._currentRange;
299✔
474
    const total = this.data.length;
299✔
475

299✔
476
    if (total > 0 && range.endIndex >= total - REMOTE_SCROLLING_THRESHOLD) {
430✔
477
      this._hasPendingDataRequest = true;
79✔
478
      this.emitEvent('igcDataRequest', {
79✔
479
        detail: {
79✔
480
          startIndex: total,
79✔
481
          count: Math.max(this.overScan * 4, 20),
79✔
482
        },
79✔
483
      });
79✔
484
    }
79✔
485
  }
430✔
486

9✔
487
  private _dispose(): void {
9✔
488
    if (this._onScroll) {
129✔
489
      this.removeEventListener('scroll', this._onScroll);
129✔
490
      this._onScroll = null;
129✔
491
    }
129✔
492
    this._itemResizeObserver?.disconnect();
129✔
493
    this._itemResizeObserver = null;
129✔
494
  }
129✔
495

9✔
496
  private _nextFrame(): Promise<void> {
9✔
497
    return new Promise((resolve) => requestAnimationFrame(() => resolve()));
131✔
498
  }
131✔
499

9✔
500
  /**
9✔
501
   * Waits for the current update to finish and then gives any
9✔
502
   * ResizeObserver-driven item measurements a chance to run. If those
9✔
503
   * measurements schedule a follow-up render (e.g. because an estimated
9✔
504
   * item size was replaced with its real, measured size), the wait is
9✔
505
   * repeated until no further renders are pending, up to a safety cap.
9✔
506
   */
9✔
507
  private async _resolveLayoutComplete(): Promise<void> {
9✔
508
    await this.updateComplete;
93✔
509

93✔
510
    for (let i = 0; i < MAX_LAYOUT_SETTLE_PASSES; i++) {
93✔
511
      await this._nextFrame();
93✔
512

93✔
513
      if (!this.isUpdatePending) {
93✔
514
        break;
93✔
515
      }
93!
NEW
516

×
NEW
517
      await this.updateComplete;
×
NEW
518
    }
×
519

93✔
520
    this._layoutCompletePromise = null;
93✔
521
  }
93✔
522

9✔
523
  //#endregion
9✔
524

9✔
525
  //#region Public API
9✔
526

9✔
527
  /* blazorSuppress */
9✔
528
  /**
9✔
529
   * A promise that resolves once the virtual scroll has fully settled:
9✔
530
   * the current render pass has completed *and* any item-size
9✔
531
   * measurements it triggers (and the renders those in turn schedule)
9✔
532
   * have also completed.
9✔
533
   *
9✔
534
   * Unlike `updateComplete`, which only reflects a single Lit render
9✔
535
   * pass, `layoutComplete` is useful after changing `data`, scrolling,
9✔
536
   * or resizing the viewport, when the final, stable DOM state may only
9✔
537
   * be reached after one or more follow-up renders.
9✔
538
   */
9✔
539
  public get layoutComplete(): Promise<void> {
9✔
540
    if (!this._layoutCompletePromise) {
101✔
541
      this._layoutCompletePromise = this._resolveLayoutComplete();
93✔
542
    }
93✔
543
    return this._layoutCompletePromise;
101✔
544
  }
101✔
545

9✔
546
  /**
9✔
547
   * Programmatically scrolls to the specified item index.
9✔
548
   *
9✔
549
   * Items outside the currently rendered window only have an *estimated*
9✔
550
   * size, so the very first jump may land slightly off target. Once the
9✔
551
   * scroll lands, the items around it are measured and, if that changes
9✔
552
   * their computed offset, the scroll position is corrected. This repeats
9✔
553
   * (each pass measuring items closer to the true target) until the offset
9✔
554
   * stabilizes, so the requested index ends up precisely aligned even for
9✔
555
   * far-away, never-before-rendered items.
9✔
556
   *
9✔
557
   * Returns a promise that resolves once the scroll position has settled
9✔
558
   * on the final, corrected offset. Callers that only care about the
9✔
559
   * initial (approximate) scroll can ignore the returned promise.
9✔
560
   */
9✔
561
  public async scrollToIndex(
9✔
562
    index: number,
25✔
563
    options?: ScrollIntoViewOptions
25✔
564
  ): Promise<void> {
25✔
565
    const maxIndex = Math.max(0, this.data.length - 1);
25✔
566
    const clampedIndex = Math.max(0, Math.min(index, maxIndex));
25✔
567
    const behavior = options?.behavior ?? 'auto';
25✔
568

25✔
569
    // A newer call supersedes any correction loop still running for a
25✔
570
    // previous one (e.g. rapid, repeated calls to scrollToIndex).
25✔
571
    const requestId = ++this._scrollRequestId;
25✔
572

25✔
573
    let offset = this._getAlignedScrollOffset(clampedIndex, options);
25✔
574
    this._applyScroll(offset, behavior);
25✔
575

25✔
576
    for (let i = 0; i < MAX_SCROLL_CORRECTION_PASSES; i++) {
25✔
577
      await this._waitForScrollSettled();
34✔
578
      await this.layoutComplete;
33✔
579

33✔
580
      if (requestId !== this._scrollRequestId) {
34✔
581
        return;
7✔
582
      }
7✔
583

26✔
584
      const corrected = this._getAlignedScrollOffset(clampedIndex, options);
26✔
585
      if (Math.abs(corrected - offset) < SCROLL_CORRECTION_EPSILON) {
34✔
586
        break;
17✔
587
      }
17✔
588

9✔
589
      offset = corrected;
9✔
590
      this._applyScroll(offset, 'auto');
9✔
591
    }
9✔
592
  }
25✔
593

9✔
594
  //#endregion
9✔
595
}
9✔
596

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

© 2026 Coveralls, Inc