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

IgniteUI / igniteui-webcomponents / 30339568272

28 Jul 2026 07:44AM UTC coverage: 98.315% (-0.02%) from 98.332%
30339568272

Pull #2222

github

web-flow
Merge d755e8943 into 2b4e1a5a0
Pull Request #2222: feat: Added virtualization component

6122 of 6445 branches covered (94.99%)

Branch coverage included in aggregate %.

1076 of 1089 new or added lines in 7 files covered. (98.81%)

43243 of 43766 relevant lines covered (98.81%)

1544.13 hits per line

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

97.88
/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) {
565✔
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;
565✔
189
  }
565✔
190

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

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

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

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

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

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

9✔
227
  /** @internal */
9✔
228
  public override disconnectedCallback(): void {
9✔
229
    super.disconnectedCallback();
131✔
230
    this._dispose();
131✔
231
  }
131✔
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.
434✔
235

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

434✔
244
    if (changed.has('data') || changed.has('estimatedItemSize')) {
434✔
245
      const estimatedSize = asNumber(this.estimatedItemSize);
180✔
246
      const normalizedEstimate = estimatedSize > 0 ? estimatedSize : 50;
180!
247

180✔
248
      if (changed.has('data')) {
180✔
249
        this._engine.resize(this.data.length, normalizedEstimate);
178✔
250
        this._hasPendingDataRequest = false;
178✔
251
      }
178✔
252

180✔
253
      if (changed.has('estimatedItemSize')) {
180✔
254
        this._engine.updateEstimatedSize(normalizedEstimate);
133✔
255
      }
133✔
256
    }
180✔
257

434✔
258
    if (changed.has('orientation')) {
434✔
259
      this._measureViewport();
131✔
260
      this._setupScrollListener();
131✔
261
    }
131✔
262
  }
434✔
263

9✔
264
  protected override updated(_changed: PropertyValues<this>): void {
9✔
265
    this._scheduleItemMeasurement();
434✔
266
    this._checkDataRequest();
434✔
267

434✔
268
    const range = this._currentRange;
434✔
269
    if (range.endIndex >= range.startIndex) {
434✔
270
      this.emitEvent('igcStateChange', {
227✔
271
        detail: {
227✔
272
          startIndex: range.startIndex,
227✔
273
          endIndex: range.endIndex,
227✔
274
          viewportSize: this._viewportSize,
227✔
275
          totalSize: this._engine.totalSize,
227✔
276
        },
227✔
277
      });
227✔
278
    }
227✔
279
  }
434✔
280

9✔
281
  protected override render(): TemplateResult {
9✔
282
    if (!this.itemTemplate) {
434✔
283
      return html`${nothing}`;
5✔
284
    }
5✔
285

429✔
286
    this._currentRange = this._engine.getVisibleRange(
429✔
287
      this._scrollPosition,
429✔
288
      this._viewportSize,
429✔
289
      this._normalizedOverScan,
429✔
290
      this.data.length
429✔
291
    );
429✔
292

429✔
293
    const range = this._currentRange;
429✔
294
    const count = this.data.length;
429✔
295
    const isVertical = this._isVertical;
429✔
296

429✔
297
    const trackStyle = isVertical
429✔
298
      ? { height: `${this._engine.domSize}px` }
420✔
299
      : { width: `${this._engine.domSize}px` };
9✔
300

434✔
301
    let contentPosition = this._engine.getContentPosition(range.startIndex);
434✔
302
    const physicalRangeSize = this._engine.getPhysicalRangeSize(
434✔
303
      range.startIndex,
434✔
304
      range.endIndex
434✔
305
    );
434✔
306
    contentPosition = Math.max(
434✔
307
      0,
434✔
308
      Math.min(contentPosition, this._engine.domSize - physicalRangeSize)
434✔
309
    );
434✔
310
    const isRTL = !isVertical && !isLTR(this);
434✔
311
    const contentStyle = {
434✔
312
      transform: isVertical
434✔
313
        ? `translateY(${contentPosition}px)`
420✔
314
        : `translateX(${isRTL ? -contentPosition : contentPosition}px)`,
9✔
315
    };
434✔
316

434✔
317
    const visibleItems =
434✔
318
      range.endIndex >= range.startIndex
434✔
319
        ? this.data.slice(range.startIndex, range.endIndex + 1)
227✔
320
        : [];
202✔
321

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

9✔
341
  //#endregion
9✔
342

9✔
343
  //#region Internal API
9✔
344

9✔
345
  private get _isVertical(): boolean {
9✔
346
    return this.orientation === 'vertical';
1,908✔
347
  }
1,908✔
348

9✔
349
  /** The configured `overScan`, normalized to a non-negative integer. */
9✔
350
  private get _normalizedOverScan(): number {
9✔
351
    return Math.max(0, Math.floor(asNumber(this.overScan, 2)));
508✔
352
  }
508✔
353

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

51✔
368
    const align = this._isVertical
51✔
369
      ? (options?.block ?? 'start')
48✔
370
      : (options?.inline ?? options?.block ?? 'start');
3!
371

51✔
372
    let offset = itemStart;
51✔
373
    if (align === 'center') {
51✔
374
      offset = itemStart - (this._viewportSize - itemSize) / 2;
38✔
375
    } else if (align === 'end') {
51!
NEW
376
      offset = itemStart - (this._viewportSize - itemSize);
×
NEW
377
    }
×
378

51✔
379
    return Math.max(0, offset);
51✔
380
  }
51✔
381

9✔
382
  /** Applies a scroll offset to the correct axis, accounting for RTL. */
9✔
383
  private _applyScroll(offset: number, behavior: ScrollBehavior): void {
9✔
384
    if (this._isVertical) {
34✔
385
      this.scrollTo({ top: offset, behavior });
32✔
386
    } else {
34✔
387
      this.scrollTo({ left: isLTR(this) ? offset : -offset, behavior });
2✔
388
    }
2✔
389
  }
34✔
390

9✔
391
  /** The current real scroll position on the active axis, normalized for RTL. */
9✔
392
  private _currentAxisScroll(): number {
9✔
393
    return this._isVertical
79✔
394
      ? this.scrollTop
72✔
395
      : isLTR(this)
7✔
396
        ? this.scrollLeft
3✔
397
        : -this.scrollLeft;
4✔
398
  }
79✔
399

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

34✔
414
    for (let i = 0; i < MAX_SCROLL_SETTLE_PASSES; i++) {
34✔
415
      await this._nextFrame();
38✔
416

37✔
417
      const current = this._currentAxisScroll();
37✔
418
      if (Math.abs(current - previous) < SCROLL_SETTLE_EPSILON_PX) {
38✔
419
        return;
33✔
420
      }
33✔
421
      previous = current;
4✔
422
    }
4!
423
  }
34✔
424

9✔
425
  private _measureViewport(): void {
9✔
426
    const size = this._isVertical ? this.clientHeight : this.clientWidth;
376✔
427
    if (size !== this._viewportSize) {
376✔
428
      this._viewportSize = size;
81✔
429
    }
81✔
430
  }
376✔
431

9✔
432
  private _setupScrollListener(): void {
9✔
433
    if (this._onScroll) {
262✔
434
      this.removeEventListener('scroll', this._onScroll);
131✔
435
    }
131✔
436

262✔
437
    this._onScroll = () => {
262✔
438
      this._scrollPosition = this._currentAxisScroll();
8✔
439
    };
8✔
440

262✔
441
    this.addEventListener('scroll', this._onScroll, { passive: true });
262✔
442
  }
262✔
443

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

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

939✔
454
      if (measured > 0) {
939✔
455
        this._engine.measureItem(index, measured);
939✔
456
      }
939✔
457
    }
939✔
458
  }
117✔
459

9✔
460
  private _scheduleItemMeasurement(): void {
9✔
461
    if (!this._contentRef.value) return;
434✔
462

428✔
463
    if (!this._itemResizeObserver) {
434✔
464
      this._itemResizeObserver = new ResizeObserver(this._handleItemResize);
127✔
465
    }
127✔
466

428✔
467
    this._itemResizeObserver.disconnect();
428✔
468
    for (const el of this._contentRef.value.children) {
434✔
469
      this._itemResizeObserver.observe(el);
1,798✔
470
    }
1,798✔
471
  }
434✔
472

9✔
473
  private _checkDataRequest(): void {
9✔
474
    if (this._hasPendingDataRequest) return;
434✔
475
    const range = this._currentRange;
303✔
476
    const total = this.data.length;
303✔
477

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

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

9✔
498
  private _nextFrame(): Promise<void> {
9✔
499
    return new Promise((resolve) => requestAnimationFrame(() => resolve()));
133✔
500
  }
133✔
501

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

95✔
512
    for (let i = 0; i < MAX_LAYOUT_SETTLE_PASSES; i++) {
95✔
513
      await this._nextFrame();
95✔
514

95✔
515
      if (!this.isUpdatePending) {
95✔
516
        break;
95✔
517
      }
95!
NEW
518

×
NEW
519
      await this.updateComplete;
×
NEW
520
    }
×
521

95✔
522
    this._layoutCompletePromise = null;
95✔
523
  }
95✔
524

9✔
525
  //#endregion
9✔
526

9✔
527
  //#region Public API
9✔
528

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

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

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

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

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

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

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

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

9✔
596
  //#endregion
9✔
597
}
9✔
598

9✔
599
declare global {
9✔
600
  interface HTMLElementTagNameMap {
9✔
601
    'igc-virtual-scroll': IgcVirtualScrollComponent;
9✔
602
  }
9✔
603
}
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