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

IgniteUI / igniteui-webcomponents / 30342920749

28 Jul 2026 08:34AM UTC coverage: 98.312% (-0.02%) from 98.332%
30342920749

Pull #2222

github

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

6124 of 6447 branches covered (94.99%)

Branch coverage included in aggregate %.

1084 of 1099 new or added lines in 7 files covered. (98.64%)

43251 of 43776 relevant lines covered (98.8%)

1546.98 hits per line

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

97.64
/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 SCROLL_END_TIMEOUT_MS = 2000;
9✔
36
const SCROLL_OFFSET_EPSILON_PX = 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) {
739✔
128
      const sheet = new CSSStyleSheet();
2✔
129
      sheet.replaceSync(`
2✔
130
        :where(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
        :where(igc-virtual-scroll[orientation='vertical']) {
2✔
138
          overflow-y: auto;
2✔
139
          overflow-x: hidden;
2✔
140
        }
2✔
141

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

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

2✔
153
        :where(igc-virtual-scroll) [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
        :where(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
        :where(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
        :where(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
        :where(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;
739✔
188
  }
739✔
189

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

603✔
296
    const trackStyle = isVertical
603✔
297
      ? { height: `${this._engine.domSize}px` }
594✔
298
      : { width: `${this._engine.domSize}px` };
9✔
299

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

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

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

9✔
340
  //#endregion
9✔
341

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

9✔
344
  private get _isVertical(): boolean {
9✔
345
    return this.orientation === 'vertical';
3,077✔
346
  }
3,077✔
347

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

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

47✔
367
    const align = this._isVertical
47✔
368
      ? (options?.block ?? 'start')
43✔
369
      : (options?.inline ?? options?.block ?? 'start');
4!
370

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

47✔
378
    return Math.max(0, offset);
47✔
379
  }
47✔
380

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

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

9✔
399
  /**
9✔
400
   * Applies a scroll offset to the active axis and waits for the browser to
9✔
401
   * report, via the native `scrollend` event, that the resulting scroll -
9✔
402
   * instant or smooth - has fully settled.
9✔
403
   *
9✔
404
   * `scrollend` never fires when the requested offset doesn't actually move
9✔
405
   * the scroll position, so that case is short-circuited instead of waiting
9✔
406
   * forever. A timeout fallback guards against the rare case where the
9✔
407
   * event never arrives at all (e.g. the element is disconnected mid-scroll).
9✔
408
   */
9✔
409
  private _scrollAndWaitForEnd(
9✔
410
    offset: number,
31✔
411
    behavior: ScrollBehavior
31✔
412
  ): Promise<void> {
31✔
413
    if (
31✔
414
      Math.abs(this._currentAxisScroll() - offset) < SCROLL_OFFSET_EPSILON_PX
31✔
415
    ) {
31✔
416
      return Promise.resolve();
18✔
417
    }
18✔
418

13✔
419
    const settled = new Promise<void>((resolve) => {
13✔
420
      this.addEventListener('scrollend', () => resolve(), { once: true });
13✔
421
    });
13✔
422

13✔
423
    this._applyScroll(offset, behavior);
13✔
424

13✔
425
    return Promise.race([settled, this._timeout(SCROLL_END_TIMEOUT_MS)]);
13✔
426
  }
31✔
427

9✔
428
  private _timeout(ms: number): Promise<void> {
9✔
429
    return new Promise((resolve) => setTimeout(resolve, ms));
13✔
430
  }
13✔
431

9✔
432
  private _measureViewport(): void {
9✔
433
    const size = this._isVertical ? this.clientHeight : this.clientWidth;
375✔
434
    if (size !== this._viewportSize) {
375✔
435
      this._viewportSize = size;
82✔
436
    }
82✔
437
  }
375✔
438

9✔
439
  private _setupScrollListener(): void {
9✔
440
    if (this._onScroll) {
262✔
441
      this.removeEventListener('scroll', this._onScroll);
131✔
442
    }
131✔
443

262✔
444
    this._onScroll = () => {
262✔
445
      this._scrollPosition = this._currentAxisScroll();
95✔
446
    };
95✔
447

262✔
448
    this.addEventListener('scroll', this._onScroll, { passive: true });
262✔
449
  }
262✔
450

9✔
451
  private _handleItemResize(entries: ResizeObserverEntry[]): void {
9✔
452
    for (const entry of entries) {
202✔
453
      const el = entry.target as HTMLElement;
1,913✔
454
      const index = asNumber(el.dataset.vsIndex, -1);
1,913✔
455
      if (index < 0) continue;
1,913!
456

1,913✔
457
      const measured = this._isVertical
1,913✔
458
        ? (entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height)
1,913!
NEW
459
        : (entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width);
×
460

1,913✔
461
      if (measured > 0) {
1,913✔
462
        this._engine.measureItem(index, measured);
1,913✔
463
      }
1,913✔
464
    }
1,913✔
465
  }
202✔
466

9✔
467
  private _scheduleItemMeasurement(): void {
9✔
468
    if (!this._contentRef.value) return;
608✔
469

602✔
470
    if (!this._itemResizeObserver) {
608✔
471
      this._itemResizeObserver = new ResizeObserver(this._handleItemResize);
127✔
472
    }
127✔
473

602✔
474
    this._itemResizeObserver.disconnect();
602✔
475
    for (const el of this._contentRef.value.children) {
608✔
476
      this._itemResizeObserver.observe(el);
3,976✔
477
    }
3,976✔
478
  }
608✔
479

9✔
480
  private _checkDataRequest(): void {
9✔
481
    if (this._hasPendingDataRequest) return;
608✔
482
    const range = this._currentRange;
476✔
483
    const total = this.data.length;
476✔
484

476✔
485
    if (total > 0 && range.endIndex >= total - REMOTE_SCROLLING_THRESHOLD) {
608✔
486
      this._hasPendingDataRequest = true;
80✔
487
      this.emitEvent('igcDataRequest', {
80✔
488
        detail: {
80✔
489
          startIndex: total,
80✔
490
          count: Math.max(this._normalizedOverScan * 4, 20),
80✔
491
        },
80✔
492
      });
80✔
493
    }
80✔
494
  }
608✔
495

9✔
496
  private _dispose(): void {
9✔
497
    if (this._onScroll) {
131✔
498
      this.removeEventListener('scroll', this._onScroll);
131✔
499
      this._onScroll = null;
131✔
500
    }
131✔
501
    this._itemResizeObserver?.disconnect();
131✔
502
    this._itemResizeObserver = null;
131✔
503
  }
131✔
504

9✔
505
  private _nextFrame(): Promise<void> {
9✔
506
    return new Promise((resolve) => requestAnimationFrame(() => resolve()));
90✔
507
  }
90✔
508

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

90✔
519
    for (let i = 0; i < MAX_LAYOUT_SETTLE_PASSES; i++) {
90✔
520
      await this._nextFrame();
90✔
521

90✔
522
      if (!this.isUpdatePending) {
90✔
523
        break;
90✔
524
      }
90!
NEW
525

×
NEW
526
      await this.updateComplete;
×
NEW
527
    }
×
528

90✔
529
    this._layoutCompletePromise = null;
90✔
530
  }
90✔
531

9✔
532
  //#endregion
9✔
533

9✔
534
  //#region Public API
9✔
535

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

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

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

25✔
582
    let offset = this._getAlignedScrollOffset(clampedIndex, options);
25✔
583
    await this._scrollAndWaitForEnd(offset, behavior);
25✔
584

25✔
585
    for (let i = 0; i < MAX_SCROLL_CORRECTION_PASSES; i++) {
25✔
586
      await this.layoutComplete;
29✔
587

29✔
588
      if (requestId !== this._scrollRequestId) {
29✔
589
        return;
7✔
590
      }
7✔
591

22✔
592
      const corrected = this._getAlignedScrollOffset(clampedIndex, options);
22✔
593
      if (Math.abs(corrected - offset) < SCROLL_OFFSET_EPSILON_PX) {
29✔
594
        break;
16✔
595
      }
16✔
596

6✔
597
      offset = corrected;
6✔
598
      await this._scrollAndWaitForEnd(offset, 'auto');
6✔
599

4✔
600
      if (requestId !== this._scrollRequestId) {
29!
NEW
601
        return;
×
NEW
602
      }
×
603
    }
29✔
604
  }
25✔
605

9✔
606
  //#endregion
9✔
607
}
9✔
608

9✔
609
declare global {
9✔
610
  interface HTMLElementTagNameMap {
9✔
611
    'igc-virtual-scroll': IgcVirtualScrollComponent;
9✔
612
  }
9✔
613
}
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