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

IgniteUI / igniteui-webcomponents / 29910521557

22 Jul 2026 10:04AM UTC coverage: 98.312% (-0.02%) from 98.332%
29910521557

Pull #2222

github

web-flow
Merge 454550177 into 56f91ebc1
Pull Request #2222: feat: Added virtualization component

6106 of 6429 branches covered (94.98%)

Branch coverage included in aggregate %.

1006 of 1019 new or added lines in 7 files covered. (98.72%)

43174 of 43697 relevant lines covered (98.8%)

1545.97 hits per line

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

97.85
/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_SETTLE_EPSILON_PX = 1;
9✔
37
const SCROLL_OFFSET_EPSILON_PX = 1;
9✔
38

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

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

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

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

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

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

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

9✔
79
  //#endregion
9✔
80

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

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

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

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

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

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

9✔
123
  //#endregion
9✔
124

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

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

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

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

2✔
148
        :where(igc-virtual-scroll) [part="igc-vs-track"] {
2✔
149
          position: relative;
2✔
150
          width: 100%;
2✔
151
          min-height: 100%;
2✔
152
        }
2✔
153

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

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

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

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

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

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

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

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

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

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

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

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

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

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

426✔
244
    if (changed.has('data') || changed.has('estimatedItemSize')) {
426✔
245
      const estimatedSize = asNumber(this.estimatedItemSize);
176✔
246
      this._engine.resize(
176✔
247
        this.data.length,
176✔
248
        estimatedSize > 0 ? estimatedSize : 50
176!
249
      );
176✔
250
      this._hasPendingDataRequest = false;
176✔
251
    }
176✔
252

426✔
253
    if (changed.has('orientation')) {
426✔
254
      this._measureViewport();
129✔
255
      this._setupScrollListener();
129✔
256
    }
129✔
257
  }
426✔
258

9✔
259
  protected override updated(_changed: PropertyValues<this>): void {
9✔
260
    this._scheduleItemMeasurement();
426✔
261
    this._checkDataRequest();
426✔
262

426✔
263
    const range = this._currentRange;
426✔
264
    if (range.endIndex >= range.startIndex) {
426✔
265
      this.emitEvent('igcStateChange', {
219✔
266
        detail: {
219✔
267
          startIndex: range.startIndex,
219✔
268
          endIndex: range.endIndex,
219✔
269
          viewportSize: this._viewportSize,
219✔
270
          totalSize: this._engine.totalSize,
219✔
271
        },
219✔
272
      });
219✔
273
    }
219✔
274
  }
426✔
275

9✔
276
  protected override render(): TemplateResult {
9✔
277
    if (!this.itemTemplate) {
426✔
278
      return html`${nothing}`;
5✔
279
    }
5✔
280

421✔
281
    this._currentRange = this._engine.getVisibleRange(
421✔
282
      this._scrollPosition,
421✔
283
      this._viewportSize,
421✔
284
      this._normalizedOverScan,
421✔
285
      this.data.length
421✔
286
    );
421✔
287

421✔
288
    const range = this._currentRange;
421✔
289
    const count = this.data.length;
421✔
290
    const isVertical = this._isVertical;
421✔
291

421✔
292
    const trackStyle = isVertical
421✔
293
      ? { height: `${this._engine.domSize}px` }
412✔
294
      : { width: `${this._engine.domSize}px` };
9✔
295

426✔
296
    let contentPosition = this._engine.getContentPosition(range.startIndex);
426✔
297
    const physicalRangeSize = this._engine.getPhysicalRangeSize(
426✔
298
      range.startIndex,
426✔
299
      range.endIndex
426✔
300
    );
426✔
301
    contentPosition = Math.max(
426✔
302
      0,
426✔
303
      Math.min(contentPosition, this._engine.domSize - physicalRangeSize)
426✔
304
    );
426✔
305
    const isRTL = !isVertical && !isLTR(this);
426✔
306
    const contentStyle = {
426✔
307
      transform: isVertical
426✔
308
        ? `translateY(${contentPosition}px)`
412✔
309
        : `translateX(${isRTL ? -contentPosition : contentPosition}px)`,
9✔
310
    };
426✔
311

426✔
312
    const visibleItems =
426✔
313
      range.endIndex >= range.startIndex
426✔
314
        ? this.data.slice(range.startIndex, range.endIndex + 1)
219✔
315
        : [];
202✔
316

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

9✔
336
  //#endregion
9✔
337

9✔
338
  //#region Internal API
9✔
339

9✔
340
  private get _isVertical(): boolean {
9✔
341
    return this.orientation === 'vertical';
1,837✔
342
  }
1,837✔
343

9✔
344
  /** The configured `overScan`, normalized to a non-negative integer. */
9✔
345
  private get _normalizedOverScan(): number {
9✔
346
    return Math.max(0, Math.floor(asNumber(this.overScan, 2)));
500✔
347
  }
500✔
348

9✔
349
  /**
9✔
350
   * Computes the scroll offset that aligns the given item index within the
9✔
351
   * viewport according to `options`, using the engine's *current* size
9✔
352
   * data. As more items get measured, calling this again for the same
9✔
353
   * index/options can yield a different, more accurate result.
9✔
354
   */
9✔
355
  private _getAlignedScrollOffset(
9✔
356
    index: number,
50✔
357
    options?: ScrollIntoViewOptions
50✔
358
  ): number {
50✔
359
    const itemStart = this._engine.getScrollOffsetForIndex(index);
50✔
360
    const itemEnd = this._engine.getScrollOffsetForIndex(index + 1);
50✔
361
    const itemSize = Math.max(0, itemEnd - itemStart);
50✔
362

50✔
363
    const align = this._isVertical
50✔
364
      ? (options?.block ?? 'start')
47✔
365
      : (options?.inline ?? options?.block ?? 'start');
3!
366

50✔
367
    let offset = itemStart;
50✔
368
    if (align === 'center') {
50✔
369
      offset = itemStart - (this._viewportSize - itemSize) / 2;
38✔
370
    } else if (align === 'end') {
50!
NEW
371
      offset = itemStart - (this._viewportSize - itemSize);
×
NEW
372
    }
×
373

50✔
374
    return Math.max(0, offset);
50✔
375
  }
50✔
376

9✔
377
  /** Applies a scroll offset to the correct axis, accounting for RTL. */
9✔
378
  private _applyScroll(offset: number, behavior: ScrollBehavior): void {
9✔
379
    if (this._isVertical) {
33✔
380
      this.scrollTo({ top: offset, behavior });
31✔
381
    } else {
33✔
382
      this.scrollTo({ left: isLTR(this) ? offset : -offset, behavior });
2✔
383
    }
2✔
384
  }
33✔
385

9✔
386
  /** The current real scroll position on the active axis, normalized for RTL. */
9✔
387
  private _currentAxisScroll(): number {
9✔
388
    return this._isVertical
77✔
389
      ? this.scrollTop
70✔
390
      : isLTR(this)
7✔
391
        ? this.scrollLeft
3✔
392
        : -this.scrollLeft;
4✔
393
  }
77✔
394

9✔
395
  /**
9✔
396
   * Waits until the real scroll position on the active axis stops moving
9✔
397
   * between two consecutive frames.
9✔
398
   *
9✔
399
   * `behavior: 'smooth'` scrolls animate asynchronously over multiple
9✔
400
   * frames, so the DOM (and the items rendered around it) only reflect the
9✔
401
   * final position once that animation finishes. Measuring items - and
9✔
402
   * correcting the target offset from those measurements - before that
9✔
403
   * happens would use data from wherever the animation currently happens
9✔
404
   * to be, not from the requested destination.
9✔
405
   */
9✔
406
  private async _waitForScrollSettled(): Promise<void> {
9✔
407
    let previous = this._currentAxisScroll();
33✔
408

33✔
409
    for (let i = 0; i < MAX_SCROLL_SETTLE_PASSES; i++) {
33✔
410
      await this._nextFrame();
38✔
411

37✔
412
      const current = this._currentAxisScroll();
37✔
413
      if (Math.abs(current - previous) < SCROLL_SETTLE_EPSILON_PX) {
38✔
414
        return;
32✔
415
      }
32✔
416
      previous = current;
5✔
417
    }
5!
418
  }
33✔
419

9✔
420
  private _measureViewport(): void {
9✔
421
    const size = this._isVertical ? this.clientHeight : this.clientWidth;
371✔
422
    if (size !== this._viewportSize) {
371✔
423
      this._viewportSize = size;
80✔
424
    }
80✔
425
  }
371✔
426

9✔
427
  private _setupScrollListener(): void {
9✔
428
    if (this._onScroll) {
258✔
429
      this.removeEventListener('scroll', this._onScroll);
129✔
430
    }
129✔
431

258✔
432
    this._onScroll = () => {
258✔
433
      this._scrollPosition = this._currentAxisScroll();
7✔
434
    };
7✔
435

258✔
436
    this.addEventListener('scroll', this._onScroll, { passive: true });
258✔
437
  }
258✔
438

9✔
439
  private _handleItemResize(entries: ResizeObserverEntry[]): void {
9✔
440
    for (const entry of entries) {
112✔
441
      const el = entry.target as HTMLElement;
885✔
442
      const index = asNumber(el.dataset.vsIndex, -1);
885✔
443
      if (index < 0) continue;
885!
444

885✔
445
      const measured = this._isVertical
885✔
446
        ? (entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height)
885!
NEW
447
        : (entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width);
×
448

885✔
449
      if (measured > 0) {
885✔
450
        this._engine.measureItem(index, measured);
885✔
451
      }
885✔
452
    }
885✔
453
  }
112✔
454

9✔
455
  private _scheduleItemMeasurement(): void {
9✔
456
    if (!this._contentRef.value) return;
426✔
457

420✔
458
    if (!this._itemResizeObserver) {
426✔
459
      this._itemResizeObserver = new ResizeObserver(this._handleItemResize);
125✔
460
    }
125✔
461

420✔
462
    this._itemResizeObserver.disconnect();
420✔
463
    for (const el of this._contentRef.value.children) {
426✔
464
      this._itemResizeObserver.observe(el);
1,715✔
465
    }
1,715✔
466
  }
426✔
467

9✔
468
  private _checkDataRequest(): void {
9✔
469
    if (this._hasPendingDataRequest) return;
426✔
470
    const range = this._currentRange;
296✔
471
    const total = this.data.length;
296✔
472

296✔
473
    if (total > 0 && range.endIndex >= total - REMOTE_SCROLLING_THRESHOLD) {
426✔
474
      this._hasPendingDataRequest = true;
79✔
475
      this.emitEvent('igcDataRequest', {
79✔
476
        detail: {
79✔
477
          startIndex: total,
79✔
478
          count: Math.max(this._normalizedOverScan * 4, 20),
79✔
479
        },
79✔
480
      });
79✔
481
    }
79✔
482
  }
426✔
483

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

9✔
493
  private _nextFrame(): Promise<void> {
9✔
494
    return new Promise((resolve) => requestAnimationFrame(() => resolve()));
130✔
495
  }
130✔
496

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

92✔
507
    for (let i = 0; i < MAX_LAYOUT_SETTLE_PASSES; i++) {
92✔
508
      await this._nextFrame();
92✔
509

92✔
510
      if (!this.isUpdatePending) {
92✔
511
        break;
92✔
512
      }
92!
NEW
513

×
NEW
514
      await this.updateComplete;
×
NEW
515
    }
×
516

92✔
517
    this._layoutCompletePromise = null;
92✔
518
  }
92✔
519

9✔
520
  //#endregion
9✔
521

9✔
522
  //#region Public API
9✔
523

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

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

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

25✔
570
    let offset = this._getAlignedScrollOffset(clampedIndex, options);
25✔
571
    this._applyScroll(offset, behavior);
25✔
572

25✔
573
    for (let i = 0; i < MAX_SCROLL_CORRECTION_PASSES; i++) {
25✔
574
      await this._waitForScrollSettled();
33✔
575
      await this.layoutComplete;
32✔
576

32✔
577
      if (requestId !== this._scrollRequestId) {
33✔
578
        return;
7✔
579
      }
7✔
580

25✔
581
      const corrected = this._getAlignedScrollOffset(clampedIndex, options);
25✔
582
      if (Math.abs(corrected - offset) < SCROLL_OFFSET_EPSILON_PX) {
33✔
583
        break;
17✔
584
      }
17✔
585

8✔
586
      offset = corrected;
8✔
587
      this._applyScroll(offset, 'auto');
8✔
588
    }
8✔
589
  }
25✔
590

9✔
591
  //#endregion
9✔
592
}
9✔
593

9✔
594
declare global {
9✔
595
  interface HTMLElementTagNameMap {
9✔
596
    'igc-virtual-scroll': IgcVirtualScrollComponent;
9✔
597
  }
9✔
598
}
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