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

IgniteUI / igniteui-angular / 26023601418

18 May 2026 08:57AM UTC coverage: 4.854% (-85.3%) from 90.174%
26023601418

Pull #17281

github

web-flow
Merge e7ce7a18e into 5a85df190
Pull Request #17281: feat: Added virtual scroll component and sample implementation

400 of 17347 branches covered (2.31%)

Branch coverage included in aggregate %.

63 of 222 new or added lines in 4 files covered. (28.38%)

27932 existing lines in 341 files now uncovered.

2022 of 32547 relevant lines covered (6.21%)

0.72 hits per line

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

83.87
/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/scroll-engine.ts
1
import { computed, signal } from "@angular/core";
2

3
const MAX_BROWSER_SIZE_PROBE_PX = Number.MAX_SAFE_INTEGER;
3✔
4

5
/**
6
 * Probes the browser for the maximum scrollable coordinate it supports.
7
 */
8
function getMaxBrowserSizeProbePx(doc: Document): number {
NEW
9
  const div = doc.createElement("div");
×
NEW
10
  div.style.position = "absolute";
×
NEW
11
  div.style.top = `${MAX_BROWSER_SIZE_PROBE_PX}px`;
×
NEW
12
  doc.body.appendChild(div);
×
NEW
13
  const size = Math.abs(div.getBoundingClientRect().top);
×
NEW
14
  doc.body.removeChild(div);
×
NEW
15
  return size;
×
16
}
17

18
/**
19
 * Builds a prefix sums array from the given sizes array.
20
 * The prefix sums array has one more element than the sizes array,
21
 * where the first element is 0 and each subsequent element is the sum of all previous sizes.
22
 * This allows for efficient calculation of the total size up to any index in the sizes array.
23
 */
24
function buildPrefixSums(sizes: readonly number[]): number[] {
25
  const sums = new Array<number>(sizes.length + 1);
26✔
26
  sums[0] = 0;
26✔
27
  for (let i = 0; i < sizes.length; i++) {
26✔
28
    sums[i + 1] = sums[i] + sizes[i];
126✔
29
  }
30
  return sums;
26✔
31
}
32

33
/**
34
 * Performs a binary search on the prefix sums array to find the largest index such that prefixSums[index] <= target.
35
 * This is used to efficiently determine how many items can fit within a given scroll position.
36
 * The function returns the index of the last item that fits within the target scroll position.
37
 * If the target is smaller than the first prefix sum, it returns -1, indicating that no items fit.
38
 */
39
function binarySearchPrefixSums(
40
  prefixSums: readonly number[],
41
  target: number,
42
): number {
43
  let low = 0;
4✔
44
  let high = prefixSums.length - 1;
4✔
45

46
  while (low < high) {
4✔
47
    const mid = (low + high + 1) >> 1;
15✔
48
    if (prefixSums[mid] <= target) {
15✔
49
      low = mid;
11✔
50
    } else {
51
      high = mid - 1;
4✔
52
    }
53
  }
54

55
  return Math.max(0, low - 1);
4✔
56
}
57

58
/**
59
 * Describes the currently visible (and over-scanned) range of items.
60
 */
61
export interface VisibleRange {
62
  /** Index of the first rendered item (inclusive) */
63
  startIndex: number;
64
  /** Index of the last rendered item (inclusive) */
65
  endIndex: number;
66
}
67

68
/**
69
 * Pure scroll-math engine for a single axis of virtual scrolling.
70
 *
71
 * Holds all size state as signals so that downstream `computed()` values
72
 * (visible range, spacer size, translate offset) react automatically
73
 * whenever item sizes are measured or the item count changes.
74
 */
75
export class VirtualScrollEngine {
76
  private _maxBrowserSize = Infinity;
19✔
77

78
  /**
79
   * The ratio `totalSize / maxBrowserSize` when `totalSize` exceeds the
80
   * maximum DOM coordinate the browser supports; `1` otherwise.
81
   * Used to map virtual scroll positions to DOM scroll positions.
82
   */
83
  private _virtualRatio = 1;
19✔
84

85
  /** Per-item measured or estimated sizes in px. */
86
  private readonly _itemSizes = signal<number[]>([]);
19✔
87

88
  /**
89
   * Prefix-sum array of item sizes, where prefixSums[i] is the total size of items[0] through items[i-1].
90
   */
91
  public readonly prefixSums = computed<number[]>(() =>
19✔
92
    buildPrefixSums(this._itemSizes()),
26✔
93
  );
94

95
  /** Total virtual size of all items in px. */
96
  public readonly totalSize = computed<number>(() => {
19✔
97
    const pSum = this.prefixSums();
26✔
98
    return pSum[pSum.length - 1] ?? 0;
26!
99
  });
100

101
  /** Actual DOM space size (clamped to the maximum browser size) */
102
  public readonly domSize = computed<number>(() =>
19✔
NEW
103
    this._virtualRatio !== 1 ? this._maxBrowserSize : this.totalSize(),
×
104
  );
105

106
  /**
107
   * Initializes the maximum browser size by probing the document, and updates the virtual ratio accordingly.
108
   */
109
  public initMaxBrowserSize(doc: Document): void {
NEW
110
    this._maxBrowserSize = getMaxBrowserSizeProbePx(doc);
×
NEW
111
    this._updateVirtualRatio();
×
112
  }
113

114
  /**
115
   * Grows or shrinks the internal sizes array to `length`.
116
   * New entries are filled with `estimatedSize`.
117
   * Existing measured sizes are preserved.
118
   */
119
  public resize(length: number, estimatedSize: number): void {
120
    const current = this._itemSizes();
22✔
121
    if (length === current.length) return;
22✔
122

123
    const next = current.slice(0, length);
20✔
124
    while (next.length < length) {
20✔
125
      next.push(estimatedSize);
99✔
126
    }
127
    this._itemSizes.set(next);
20✔
128
    this._updateVirtualRatio();
20✔
129
  }
130

131
  /**
132
   * Records the measured DOM size for a single item.
133
   * Triggers a signal update so all downstream computed values react.
134
   */
135
  public measureItem(index: number, size: number): void {
136
    const current = this._itemSizes();
9✔
137
    if (index < 0 || index >= current.length) return;
9✔
138
    if (current[index] === size) return;
7✔
139

140
    const next = current.slice();
6✔
141
    next[index] = size;
6✔
142
    this._itemSizes.set(next);
6✔
143
    this._updateVirtualRatio();
6✔
144
  }
145

146
  /**
147
   * Returns the DOM scroll offset in pixels that brings item at `index` into view
148
   * at the leading edge of the viewport.
149
   */
150
  public getScrollOffsetForIndex(index: number): number {
151
    const pSums = this.prefixSums();
3✔
152
    if (index <= 0) return 0;
3✔
153

154
    const clamped = Math.min(index, pSums.length - 1);
2✔
155
    const virtualOffset = pSums[clamped];
2✔
156
    return virtualOffset / this._virtualRatio;
2✔
157
  }
158

159
  /** Returns the item index at the given DOM scroll position. */
160
  public getIndexAtScroll(scrollPosition: number): number {
161
    const virtualPosition = scrollPosition * this._virtualRatio;
8✔
162
    const pSum = this.prefixSums();
8✔
163
    if (virtualPosition <= 0 || pSum.length <= 1) return 0;
8✔
164

165
    return binarySearchPrefixSums(pSum, virtualPosition);
4✔
166
  }
167

168
  /**
169
   * Returns the visible + over-scanned item range for the given scroll state.
170
   */
171
  public getVisibleRange(
172
    scrollPosition: number,
173
    viewportSize: number,
174
    overScan: number,
175
    totalItems: number,
176
  ): VisibleRange {
177
    if (totalItems === 0 || viewportSize <= 0) {
5✔
178
      return { startIndex: 0, endIndex: -1 };
2✔
179
    }
180

181
    const start = Math.max(0, this.getIndexAtScroll(scrollPosition) - overScan);
3✔
182
    const endScrollPosition = scrollPosition + viewportSize;
3✔
183
    const endRaw = this.getIndexAtScroll(endScrollPosition);
3✔
184
    const end = Math.min(totalItems - 1, endRaw + overScan);
3✔
185

186
    return { startIndex: start, endIndex: end };
3✔
187
  }
188

189
  /**
190
   * Returns the CSS `translateY` / `translateX` value (px) to apply to the
191
   * absolutely-positioned content wrapper.
192
   *
193
   * The content wrapper is `position: absolute; top: 0; left: 0` inside a
194
   * track element that is `totalSize` px tall/wide. Translating it to
195
   * `getContentPosition(startIndex)` places the first rendered item exactly
196
   * at its virtual scroll position within the track.
197
   */
198
  public getContentPosition(index: number): number {
199
    const pSums = this.prefixSums();
2✔
200
    if (index <= 0) return 0;
2✔
201

202
    const clamped = Math.min(index, pSums.length - 1);
1✔
203
    const virtualOffset = pSums[clamped];
1✔
204
    return virtualOffset / this._virtualRatio;
1✔
205
  }
206

207
  private _updateVirtualRatio(): void {
208
    const totalSize = this.totalSize();
26✔
209
    this._virtualRatio =
26✔
210
      this._maxBrowserSize === Infinity || totalSize <= this._maxBrowserSize
52!
211
        ? 1
212
        : totalSize / this._maxBrowserSize;
213
  }
214
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc