• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In
Build has been canceled!

IgniteUI / igniteui-webcomponents / 31161261227

07 Aug 2026 08:20AM UTC coverage: 98.375% (+0.02%) from 98.359%
31161261227

push

github

web-flow
fix(date-time-input): only commit value on blur (#1346) (#2312)

`value` no longer moves on every keystroke. While the input is focused the
masked text is the source of truth, and the parsed result reaches `value`
together with `igcChange` when the edit is committed on blur. Use `igcInput`
to observe the value as it is typed - its ISO-string detail is unchanged.

This is what the annotated two-way binding always claimed: framework wrappers
treat `igcChange` as the only signal that `value` moved, so a host that binds
the property held a stale value for the whole editing session. On every
keystroke it re-committed that stale value, and the setter rebuilt the mask
from it, wiping the half-typed input - reported against an igx-grid cell
editor, where an unrelated re-render was enough to trigger it.

The draft alone does not fix that, since the re-applied value is an equal but
not identical Date and passes Lit's dirty check. So the setter is now
idempotent: an assignment that deep-equals the current value is a no-op and
cannot disturb an edit in progress.

Spinning and Ctrl+; while focused move the draft; unfocused stepUp/stepDown,
clear(), setRangeText() and drops still commit immediately, as they have no
blur coming to commit them. The pickers stop assigning their own value on
igcInput and read the draft instead, so the calendar keeps following along
while the user types.

Same treatment for igc-date-range-input, and the shared logic - commit,
draft, mask display, clear and setRangeText - now lives once in
IgcDateTimeInputBaseComponent, which became generic over its value type.
Both concrete inputs come out smaller than before the fix.

Behavioral change: reading `.value` mid-typing now returns the last committed
value.

Closes #1346

6525 of 6855 branches covered (95.19%)

Branch coverage included in aggregate %.

302 of 303 new or added lines in 6 files covered. (99.67%)

1 existing line in 1 file now uncovered.

46565 of 47112 relevant lines covered (98.84%)

1806.31 hits per line

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

97.63
/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) {
789✔
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;
789✔
188
  }
789✔
189

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

647✔
321
    return html`
647✔
322
      <div part="igc-vs-track" style=${styleMap(trackStyle)}>
647✔
323
        <div
647✔
324
          ${ref(this._contentRef)}
647✔
325
          part="igc-vs-content"
647✔
326
          style=${styleMap(contentStyle)}
647✔
327
        >
647✔
328
          ${visibleItems.map((item, i) => {
647✔
329
            const itemIndex = range.startIndex + i;
4,097✔
330
            const ctx = new VirtualScrollItemContext(item, itemIndex, count);
4,097✔
331
            return html`<div data-vs-index=${itemIndex}>
4,097✔
332
              ${this.itemTemplate!(ctx)}
4,097✔
333
            </div>`;
4,097✔
334
          })}
647✔
335
        </div>
647✔
336
      </div>
647✔
337
    `;
647✔
338
  }
647✔
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,212✔
346
  }
3,212✔
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)));
733✔
351
  }
733✔
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,
51✔
361
    options?: ScrollIntoViewOptions
51✔
362
  ): number {
51✔
363
    const itemStart = this._engine.getScrollOffsetForIndex(index);
51✔
364
    const itemEnd = this._engine.getScrollOffsetForIndex(index + 1);
51✔
365
    const itemSize = Math.max(0, itemEnd - itemStart);
51✔
366

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

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

51✔
378
    return Math.max(0, offset);
51✔
379
  }
51✔
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,
32✔
411
    behavior: ScrollBehavior
32✔
412
  ): Promise<void> {
32✔
413
    if (
32✔
414
      Math.abs(this._currentAxisScroll() - offset) < SCROLL_OFFSET_EPSILON_PX
32✔
415
    ) {
32✔
416
      return Promise.resolve();
19✔
417
    }
19✔
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
  }
32✔
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;
406✔
434
    if (size !== this._viewportSize) {
406✔
435
      this._viewportSize = size;
91✔
436
    }
91✔
437
  }
406✔
438

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

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

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

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

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

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

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

640✔
470
    if (!this._itemResizeObserver) {
647✔
471
      this._itemResizeObserver = new ResizeObserver(this._handleItemResize);
138✔
472
    }
138✔
473

640✔
474
    this._itemResizeObserver.disconnect();
640✔
475
    for (const el of this._contentRef.value.children) {
647✔
476
      this._itemResizeObserver.observe(el);
4,081✔
477
    }
4,081✔
478
  }
647✔
479

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

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

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

9✔
505
  private _nextFrame(): Promise<void> {
9✔
506
    return new Promise((resolve) => requestAnimationFrame(() => resolve()));
106✔
507
  }
106✔
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;
106✔
518

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

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

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

106✔
529
    this._layoutCompletePromise = null;
106✔
530
  }
106✔
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) {
115✔
550
      this._layoutCompletePromise = this._resolveLayoutComplete();
106✔
551
    }
106✔
552
    return this._layoutCompletePromise;
115✔
553
  }
115✔
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,
26✔
572
    options?: ScrollIntoViewOptions
26✔
573
  ): Promise<void> {
26✔
574
    const maxIndex = Math.max(0, this.data.length - 1);
26✔
575
    const clampedIndex = Math.max(0, Math.min(index, maxIndex));
26✔
576
    const behavior = options?.behavior ?? 'auto';
26✔
577

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

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

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

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

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

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

6✔
600
      if (requestId !== this._scrollRequestId) {
32!
601
        return;
×
602
      }
×
603
    }
32✔
604
  }
26✔
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