igniteui-angular 22.2.0-rc.1 → 22.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +1 -1
  2. package/button-group/README.md +42 -9
  3. package/calendar/README.md +30 -20
  4. package/card/README.md +1 -1
  5. package/fesm2022/igniteui-angular-accordion.mjs +7 -7
  6. package/fesm2022/igniteui-angular-action-strip.mjs +11 -22
  7. package/fesm2022/igniteui-angular-action-strip.mjs.map +1 -1
  8. package/fesm2022/igniteui-angular-avatar.mjs +7 -7
  9. package/fesm2022/igniteui-angular-badge.mjs +7 -7
  10. package/fesm2022/igniteui-angular-banner.mjs +10 -10
  11. package/fesm2022/igniteui-angular-bottom-nav.mjs +22 -22
  12. package/fesm2022/igniteui-angular-button-group.mjs +32 -35
  13. package/fesm2022/igniteui-angular-button-group.mjs.map +1 -1
  14. package/fesm2022/igniteui-angular-calendar.mjs +132 -180
  15. package/fesm2022/igniteui-angular-calendar.mjs.map +1 -1
  16. package/fesm2022/igniteui-angular-card.mjs +47 -58
  17. package/fesm2022/igniteui-angular-card.mjs.map +1 -1
  18. package/fesm2022/igniteui-angular-carousel.mjs +22 -22
  19. package/fesm2022/igniteui-angular-chat-extras.mjs +6 -6
  20. package/fesm2022/igniteui-angular-chat.mjs +12 -12
  21. package/fesm2022/igniteui-angular-checkbox.mjs +7 -7
  22. package/fesm2022/igniteui-angular-chips.mjs +10 -10
  23. package/fesm2022/igniteui-angular-combo.mjs +66 -60
  24. package/fesm2022/igniteui-angular-combo.mjs.map +1 -1
  25. package/fesm2022/igniteui-angular-core.mjs +107 -79
  26. package/fesm2022/igniteui-angular-core.mjs.map +1 -1
  27. package/fesm2022/igniteui-angular-date-picker.mjs +38 -38
  28. package/fesm2022/igniteui-angular-dialog.mjs +13 -13
  29. package/fesm2022/igniteui-angular-directives.mjs +194 -194
  30. package/fesm2022/igniteui-angular-drop-down.mjs +29 -29
  31. package/fesm2022/igniteui-angular-expansion-panel.mjs +28 -28
  32. package/fesm2022/igniteui-angular-grids-core.mjs +737 -661
  33. package/fesm2022/igniteui-angular-grids-core.mjs.map +1 -1
  34. package/fesm2022/igniteui-angular-grids-grid.mjs +69 -58
  35. package/fesm2022/igniteui-angular-grids-grid.mjs.map +1 -1
  36. package/fesm2022/igniteui-angular-grids-hierarchical-grid.mjs +37 -37
  37. package/fesm2022/igniteui-angular-grids-lite.mjs +25 -17
  38. package/fesm2022/igniteui-angular-grids-lite.mjs.map +1 -1
  39. package/fesm2022/igniteui-angular-grids-pivot-grid.mjs +92 -81
  40. package/fesm2022/igniteui-angular-grids-pivot-grid.mjs.map +1 -1
  41. package/fesm2022/igniteui-angular-grids-tree-grid.mjs +55 -55
  42. package/fesm2022/igniteui-angular-icon.mjs +10 -10
  43. package/fesm2022/igniteui-angular-input-group.mjs +25 -25
  44. package/fesm2022/igniteui-angular-list.mjs +40 -40
  45. package/fesm2022/igniteui-angular-navbar.mjs +13 -13
  46. package/fesm2022/igniteui-angular-navigation-drawer.mjs +43 -38
  47. package/fesm2022/igniteui-angular-navigation-drawer.mjs.map +1 -1
  48. package/fesm2022/igniteui-angular-paginator.mjs +19 -19
  49. package/fesm2022/igniteui-angular-progressbar.mjs +19 -19
  50. package/fesm2022/igniteui-angular-query-builder.mjs +22 -22
  51. package/fesm2022/igniteui-angular-radio.mjs +25 -21
  52. package/fesm2022/igniteui-angular-radio.mjs.map +1 -1
  53. package/fesm2022/igniteui-angular-select.mjs +25 -25
  54. package/fesm2022/igniteui-angular-simple-combo.mjs +9 -9
  55. package/fesm2022/igniteui-angular-simple-combo.mjs.map +1 -1
  56. package/fesm2022/igniteui-angular-slider.mjs +28 -28
  57. package/fesm2022/igniteui-angular-snackbar.mjs +7 -7
  58. package/fesm2022/igniteui-angular-splitter.mjs +13 -13
  59. package/fesm2022/igniteui-angular-stepper.mjs +34 -34
  60. package/fesm2022/igniteui-angular-switch.mjs +7 -7
  61. package/fesm2022/igniteui-angular-tabs.mjs +34 -34
  62. package/fesm2022/igniteui-angular-time-picker.mjs +19 -19
  63. package/fesm2022/igniteui-angular-toast.mjs +7 -7
  64. package/fesm2022/igniteui-angular-tree.mjs +28 -28
  65. package/fesm2022/igniteui-angular-virtual-scroll.mjs +497 -145
  66. package/fesm2022/igniteui-angular-virtual-scroll.mjs.map +1 -1
  67. package/migrations/common/UpdateChanges.d.ts +25 -5
  68. package/migrations/common/UpdateChanges.js +218 -34
  69. package/migrations/common/UpdateChanges.spec.js +763 -0
  70. package/migrations/migration-collection.json +1 -1
  71. package/migrations/update-22_2_0/index.js +145 -0
  72. package/migrations/update-22_2_0/index.spec.js +133 -0
  73. package/navigation-drawer/README.md +1 -1
  74. package/package.json +4 -4
  75. package/schematics/tsconfig.tsbuildinfo +1 -1
  76. package/skills/igniteui-angular-components/SKILL.md +9 -5
  77. package/skills/igniteui-angular-components/references/form-controls.md +1 -1
  78. package/skills/igniteui-angular-components/references/mcp-setup.md +14 -2
  79. package/skills/igniteui-angular-figma-to-app/SKILL.md +112 -525
  80. package/skills/igniteui-angular-figma-to-app/references/asset-extraction.md +49 -75
  81. package/skills/igniteui-angular-figma-to-app/references/design-provenance.md +201 -0
  82. package/skills/igniteui-angular-figma-to-app/references/design-token-bridge.md +175 -52
  83. package/skills/igniteui-angular-figma-to-app/references/figma-component-map.md +153 -99
  84. package/skills/igniteui-angular-figma-to-app/references/figma-exploration.md +226 -0
  85. package/skills/igniteui-angular-figma-to-app/references/mcp-setup.md +71 -105
  86. package/skills/igniteui-angular-figma-to-app/references/project-setup.md +63 -0
  87. package/skills/igniteui-angular-figma-to-app/references/theme-generation.md +120 -0
  88. package/skills/igniteui-angular-figma-to-app/references/validation-patterns.md +48 -50
  89. package/skills/igniteui-angular-generate-from-image-design/SKILL.md +11 -7
  90. package/skills/igniteui-angular-grids/SKILL.md +7 -3
  91. package/skills/igniteui-angular-grids/references/editing.md +1 -2
  92. package/skills/igniteui-angular-grids/references/grid-migration.md +1 -1
  93. package/skills/igniteui-angular-theming/SKILL.md +9 -5
  94. package/skills/igniteui-angular-theming/references/mcp-setup.md +12 -2
  95. package/types/igniteui-angular-button-group.d.ts +49 -34
  96. package/types/igniteui-angular-calendar.d.ts +33 -50
  97. package/types/igniteui-angular-card.d.ts +12 -17
  98. package/types/igniteui-angular-combo.d.ts +6 -0
  99. package/types/igniteui-angular-core.d.ts +10 -2
  100. package/types/igniteui-angular-grids-core.d.ts +48 -4
  101. package/types/igniteui-angular-grids-grid.d.ts +4 -0
  102. package/types/igniteui-angular-grids-lite.d.ts +5 -1
  103. package/types/igniteui-angular-grids-pivot-grid.d.ts +5 -1
  104. package/types/igniteui-angular-navigation-drawer.d.ts +1 -0
  105. package/types/igniteui-angular-radio.d.ts +5 -0
  106. package/types/igniteui-angular-virtual-scroll.d.ts +41 -13
  107. package/virtual-scroll/README.md +32 -1
@@ -1,6 +1,6 @@
1
1
  import { isPlatformBrowser, NgTemplateOutlet } from '@angular/common';
2
2
  import * as i0 from '@angular/core';
3
- import { signal, computed, inject, TemplateRef, Directive, ElementRef, NgZone, DOCUMENT, PLATFORM_ID, contentChild, viewChild, input, output, effect, untracked, afterNextRender, afterRenderEffect, ViewEncapsulation, ChangeDetectionStrategy, Component } from '@angular/core';
3
+ import { signal, computed, inject, TemplateRef, Directive, ViewContainerRef, input, ElementRef, NgZone, DOCUMENT, PLATFORM_ID, contentChild, viewChild, output, effect, untracked, afterNextRender, afterRenderEffect, ViewEncapsulation, ChangeDetectionStrategy, Component } from '@angular/core';
4
4
  import { clamp, isLeftToRight } from 'igniteui-angular/core';
5
5
 
6
6
  /** The maximum scroll coordinate is a per-document constant, so probe once. */
@@ -29,51 +29,78 @@ function probeMaxBrowserSize(doc) {
29
29
  _maxBrowserSizeCache.set(doc, size);
30
30
  return size;
31
31
  }
32
- /**
33
- * Fills `tree` with the partial range sums of `sizes` in one O(N) pass.
34
- * `tree` is a 1-indexed Fenwick array of `sizes.length + 1` zeroed entries.
35
- * Returns the total sum.
36
- */
37
- function buildTree(tree, sizes) {
38
- const length = sizes.length;
39
- let total = 0;
40
- for (let i = 1; i <= length; i++) {
41
- tree[i] += sizes[i - 1];
42
- total += sizes[i - 1];
43
- const parent = i + (i & -i);
44
- if (parent <= length) {
45
- tree[parent] += tree[i];
46
- }
47
- }
48
- return total;
49
- }
32
+ /** The item holds the estimate. */
33
+ const UNMEASURED = 0;
34
+ /** The item was measured under an older estimate. */
35
+ const MEASURED = 1;
36
+ /** The item was measured under the current estimate. */
37
+ const SAMPLED = 2;
50
38
  /**
51
39
  * Binary Indexed Tree (Fenwick tree) over item sizes. Each hot-path operation
52
40
  * is O(log N): point update (item measured), prefix sum (scroll offset), and
53
41
  * index at offset (scroll to item, through binary lifting).
42
+ *
43
+ * The tree holds only the measured sizes and the number of measured items.
44
+ * Each unmeasured item counts as `estimate`, so an estimate change is O(1).
54
45
  */
55
46
  class SizeTree {
56
- constructor(sizes, tree, total, measured) {
57
- this.length = sizes.length;
47
+ /** Builds the tree in one O(N) pass. Without `states`, no item is measured. */
48
+ constructor(length, estimate, sizes = new Float64Array(length), states = new Uint8Array(length)) {
49
+ this._measuredCount = 0;
50
+ this._measuredTotal = 0;
51
+ /** Count and size sum of the `SAMPLED` items. */
52
+ this._sampleCount = 0;
53
+ this._sampleTotal = 0;
54
+ this.length = length;
55
+ this.estimate = estimate;
58
56
  this._sizes = sizes;
59
- this._tree = tree;
60
- this._total = total;
61
- this._measured = measured;
62
- this._topBit = this.length > 0 ? 1 << (31 - Math.clz32(this.length)) : 0;
63
- }
64
- /** Creates a tree of `length` unmeasured items, each set to `fillSize`. O(N). */
65
- static filled(length, fillSize) {
66
- return SizeTree._build(new Float64Array(length).fill(fillSize), new Uint8Array(length));
67
- }
68
- /** Builds a tree from a sizes array and its matching measured flags. O(N). */
69
- static _build(sizes, measured) {
70
- const tree = new Float64Array(sizes.length + 1);
71
- const total = buildTree(tree, sizes);
72
- return new SizeTree(sizes, tree, total, measured);
57
+ this._states = states;
58
+ this._sums = new Float64Array(length + 1);
59
+ this._counts = new Int32Array(length + 1);
60
+ this._topBit = length > 0 ? 1 << (31 - Math.clz32(length)) : 0;
61
+ for (let i = 1; i <= length; i++) {
62
+ const state = states[i - 1];
63
+ if (state !== UNMEASURED) {
64
+ const size = sizes[i - 1];
65
+ this._sums[i] += size;
66
+ this._counts[i]++;
67
+ this._measuredCount++;
68
+ this._measuredTotal += size;
69
+ if (state === SAMPLED) {
70
+ this._sampleCount++;
71
+ this._sampleTotal += size;
72
+ }
73
+ }
74
+ const parent = i + (i & -i);
75
+ if (parent <= length) {
76
+ this._sums[parent] += this._sums[i];
77
+ this._counts[parent] += this._counts[i];
78
+ }
79
+ }
73
80
  }
74
81
  /** Total size of all items. O(1). */
75
82
  get totalSize() {
76
- return this._total;
83
+ return this.totalSizeAt(this.estimate);
84
+ }
85
+ /** Total size of all items if unmeasured ones took `estimate`. O(1). */
86
+ totalSizeAt(estimate) {
87
+ return this._measuredTotal + (this.length - this._measuredCount) * estimate;
88
+ }
89
+ /** The number of measured items. O(1). */
90
+ get measuredCount() {
91
+ return this._measuredCount;
92
+ }
93
+ /** The average size of the `SAMPLED` items, or `0` if there are none. */
94
+ get sampleAverage() {
95
+ return this._sampleCount > 0 ? this._sampleTotal / this._sampleCount : 0;
96
+ }
97
+ /** Whether each item before `index` is measured. O(log N). */
98
+ isMeasuredBefore(index) {
99
+ let count = 0;
100
+ for (let j = index; j > 0; j -= j & -j) {
101
+ count += this._counts[j];
102
+ }
103
+ return count === index;
77
104
  }
78
105
  /**
79
106
  * Prefix sum of items [0, i): the virtual scroll offset at the leading
@@ -81,69 +108,71 @@ class SizeTree {
81
108
  */
82
109
  prefixSum(i) {
83
110
  let sum = 0;
111
+ let count = 0;
84
112
  for (let j = i; j > 0; j -= j & -j) {
85
- sum += this._tree[j];
113
+ sum += this._sums[j];
114
+ count += this._counts[j];
86
115
  }
87
- return sum;
116
+ return sum + (i - count) * this.estimate;
88
117
  }
89
118
  /**
90
- * Sets the size of the item at a 0-based index and marks it measured, so
91
- * later `applyEstimate` calls leave it alone. Returns true when the size
92
- * changed. O(log N).
119
+ * Sets the size of the item at a 0-based index and marks it `SAMPLED`.
120
+ * Returns true when the size changed. O(log N).
93
121
  */
94
- update(index, newSize) {
122
+ update(index, size) {
95
123
  if (index < 0 || index >= this.length) {
96
124
  return false;
97
125
  }
98
- const old = this._sizes[index];
99
- this._measured[index] = 1;
100
- if (old === newSize) {
101
- return false;
126
+ const state = this._states[index];
127
+ const isNew = state === UNMEASURED;
128
+ const old = isNew ? 0 : this._sizes[index];
129
+ const delta = size - old;
130
+ this._states[index] = SAMPLED;
131
+ this._sizes[index] = size;
132
+ this._measuredTotal += delta;
133
+ this._sampleTotal += state === SAMPLED ? delta : size;
134
+ if (state !== SAMPLED) {
135
+ this._sampleCount++;
102
136
  }
103
- const delta = newSize - old;
104
- this._sizes[index] = newSize;
105
- this._total += delta;
106
- for (let i = index + 1; i <= this.length; i += i & -i) {
107
- this._tree[i] += delta;
137
+ if (isNew) {
138
+ this._measuredCount++;
108
139
  }
109
- return true;
140
+ if (isNew || delta !== 0) {
141
+ for (let i = index + 1; i <= this.length; i += i & -i) {
142
+ this._sums[i] += delta;
143
+ if (isNew) {
144
+ this._counts[i]++;
145
+ }
146
+ }
147
+ }
148
+ return size !== (isNew ? this.estimate : old);
110
149
  }
111
150
  /**
112
- * Returns a new tree of `newLength` items in one O(N) pass. Sizes and
113
- * measured flags are kept up to `min(this.length, newLength, retainCount)`.
114
- * The remainder is filled with `fillSize` and marked unmeasured. Pass a
115
- * `retainCount` below the item count when the data behind those indices
116
- * changed identity.
151
+ * Returns a new tree of `newLength` items. Measured sizes are kept up to
152
+ * `min(this.length, newLength, retainCount)`, and the other items are
153
+ * unmeasured. Pass a `retainCount` below the item count when the data
154
+ * behind those indices changed identity. O(N).
117
155
  */
118
- cloneResized(newLength, fillSize, retainCount = newLength) {
119
- const sizes = new Float64Array(newLength).fill(fillSize);
120
- const measured = new Uint8Array(newLength);
156
+ cloneResized(newLength, retainCount = newLength) {
121
157
  const retained = Math.max(0, Math.min(this.length, newLength, retainCount));
158
+ const sizes = new Float64Array(newLength);
159
+ const states = new Uint8Array(newLength);
122
160
  sizes.set(this._sizes.subarray(0, retained));
123
- measured.set(this._measured.subarray(0, retained));
124
- return SizeTree._build(sizes, measured);
161
+ states.set(this._states.subarray(0, retained));
162
+ return new SizeTree(newLength, this.estimate, sizes, states);
125
163
  }
126
164
  /**
127
- * Sets `estimatedSize` on each unmeasured item. Returns true when at least
128
- * one size changed.
129
- *
130
- * One estimate change can touch most of the list, so this rebuilds in one
131
- * O(N) pass instead of one O(log N) `update` per item.
165
+ * Starts a new sample: `sampleAverage` then counts only the items measured
166
+ * after this call. O(N).
132
167
  */
133
- applyEstimate(estimatedSize) {
134
- let changed = false;
168
+ startSample() {
135
169
  for (let i = 0; i < this.length; i++) {
136
- if (!this._measured[i] && this._sizes[i] !== estimatedSize) {
137
- this._sizes[i] = estimatedSize;
138
- changed = true;
170
+ if (this._states[i] === SAMPLED) {
171
+ this._states[i] = MEASURED;
139
172
  }
140
173
  }
141
- if (!changed) {
142
- return false;
143
- }
144
- this._tree.fill(0);
145
- this._total = buildTree(this._tree, this._sizes);
146
- return true;
174
+ this._sampleCount = 0;
175
+ this._sampleTotal = 0;
147
176
  }
148
177
  /**
149
178
  * Returns the 0-based index of the item containing the scroll `offset`:
@@ -157,9 +186,14 @@ class SizeTree {
157
186
  let remaining = offset;
158
187
  for (let bit = this._topBit; bit > 0; bit >>= 1) {
159
188
  const next = index + bit;
160
- if (next <= this.length && this._tree[next] <= remaining) {
189
+ if (next > this.length) {
190
+ continue;
191
+ }
192
+ // Node `next` covers the `bit` items after `index`.
193
+ const size = this._sums[next] + (bit - this._counts[next]) * this.estimate;
194
+ if (size <= remaining) {
161
195
  index = next;
162
- remaining -= this._tree[index];
196
+ remaining -= size;
163
197
  }
164
198
  }
165
199
  return Math.min(this.length - 1, index);
@@ -184,6 +218,10 @@ class VirtualScrollEngine {
184
218
  constructor() {
185
219
  this._maxBrowserSize = Number.POSITIVE_INFINITY;
186
220
  this._tree = null;
221
+ /** The estimate last given to `resize` or `updateEstimatedSize`. */
222
+ this._configuredEstimate = Number.NaN;
223
+ /** Whether `adaptEstimate` has applied a measured average. */
224
+ this._hasAdaptedEstimate = false;
187
225
  /** Bumped on every structural change: resize, measurement or estimate. */
188
226
  this._version = signal(0, /* @ts-ignore */
189
227
  ...(ngDevMode ? [{ debugName: "_version" }] : /* istanbul ignore next */ []));
@@ -222,18 +260,24 @@ class VirtualScrollEngine {
222
260
  }
223
261
  /**
224
262
  * Resizes the internal sizes array to `length`. Measured sizes below
225
- * `retainCount` are kept, the remainder falls back to `estimatedSize`.
226
- * Callers that only append can keep the default `retainCount`. Callers
227
- * whose data changed identity at some index must pass that index, so the
228
- * stale measurements after it are discarded.
263
+ * `retainCount` are kept, and the other items are unmeasured. Callers that
264
+ * only append can keep the default `retainCount`. Callers whose data
265
+ * changed identity at some index must pass that index, so the stale
266
+ * measurements after it are discarded.
267
+ *
268
+ * Unmeasured items take `estimatedSize`, or keep the adapted average while
269
+ * `estimatedSize` is unchanged.
229
270
  */
230
271
  resize(length, estimatedSize, retainCount = length) {
231
272
  if (this._tree?.length === length && retainCount >= length) {
232
273
  return;
233
274
  }
234
- this._tree = this._tree
235
- ? this._tree.cloneResized(length, estimatedSize, retainCount)
236
- : SizeTree.filled(length, estimatedSize);
275
+ this._tree =
276
+ this._tree?.cloneResized(length, retainCount) ??
277
+ new SizeTree(length, estimatedSize);
278
+ if (this._configureEstimate(estimatedSize)) {
279
+ this._tree.estimate = estimatedSize;
280
+ }
237
281
  this._invalidate();
238
282
  }
239
283
  /** Records the measured DOM size for a single item. */
@@ -248,10 +292,40 @@ class VirtualScrollEngine {
248
292
  * because `resize` is then a no-op.
249
293
  */
250
294
  updateEstimatedSize(estimatedSize) {
251
- if (this._tree?.applyEstimate(estimatedSize)) {
252
- this._invalidate();
295
+ if (this._configureEstimate(estimatedSize)) {
296
+ this._applyEstimate(estimatedSize);
253
297
  }
254
298
  }
299
+ /**
300
+ * Sets the unmeasured estimate to the average size measured since the
301
+ * estimate was configured; older sizes may predate a density change. After
302
+ * the first call, applies only while every item before `windowStart` is
303
+ * measured and the list stays uncompressed, so rendered items cannot shift.
304
+ */
305
+ adaptEstimate(windowStart) {
306
+ const tree = this._tree;
307
+ if (!tree?.sampleAverage) {
308
+ return;
309
+ }
310
+ // Compressed before or after, the total sets the scroll ratio: a new average moves every item.
311
+ const largerTotal = tree.totalSizeAt(Math.max(tree.estimate, tree.sampleAverage));
312
+ if (this._hasAdaptedEstimate &&
313
+ (largerTotal > this._maxBrowserSize || !tree.isMeasuredBefore(windowStart))) {
314
+ return;
315
+ }
316
+ this._hasAdaptedEstimate = true;
317
+ this._applyEstimate(tree.sampleAverage);
318
+ }
319
+ /** Drops the measured sizes and the adapted estimate, keeping the item count. */
320
+ clearSizes() {
321
+ const tree = this._tree;
322
+ if (!tree || (tree.measuredCount === 0 && !this._hasAdaptedEstimate)) {
323
+ return;
324
+ }
325
+ this._tree = new SizeTree(tree.length, this._configuredEstimate);
326
+ this._hasAdaptedEstimate = false;
327
+ this._invalidate();
328
+ }
255
329
  /**
256
330
  * Returns the DOM scroll offset in px that puts the item at `index` at the
257
331
  * leading edge of a `viewportSize` px viewport.
@@ -285,29 +359,24 @@ class VirtualScrollEngine {
285
359
  else if (align === "end") {
286
360
  offset -= slack;
287
361
  }
288
- return clamp(this._toDom(offset, viewportSize), 0, Math.max(0, this.domSize() - viewportSize));
362
+ return clamp(this._toDom(offset, viewportSize), 0, this._maxDomOffset(viewportSize));
289
363
  }
290
364
  /**
291
- * The alignment `scrollIntoView({ block: 'nearest' })` resolves to for the
292
- * item at `index` at the given DOM scroll position: `start` for an item
293
- * before the viewport, `end` for one past it, and `null` for one that needs
294
- * no scrolling because it is fully inside the viewport, or larger than the
295
- * viewport and covering it.
365
+ * Returns the DOM scroll offset that brings the item at `index` into view
366
+ * as `position` asks. `nearest` follows native `scrollIntoView`: an item in
367
+ * view, or one that covers the viewport, keeps `scrollPosition`, and any
368
+ * other item scrolls the smallest distance to an edge alignment.
369
+ *
370
+ * Example: 50px items in a 300px viewport at offset 0. Item 10 spans
371
+ * 500-550, so `nearest` returns 250 (end aligned) and `start` returns 500.
296
372
  */
297
- getNearestAlignment(index, scrollPosition, viewportSize) {
298
- const bounds = this._itemBounds(index);
299
- if (!bounds) {
300
- return null;
373
+ resolveScrollOffset(index, scrollPosition, viewportSize, position) {
374
+ if (position === "nearest") {
375
+ const start = this.getAlignedScrollOffset(index, viewportSize, "start");
376
+ const end = this.getAlignedScrollOffset(index, viewportSize, "end");
377
+ return clamp(scrollPosition, Math.min(start, end), Math.max(start, end));
301
378
  }
302
- const [start, end] = bounds;
303
- const viewStart = this._toVirtual(scrollPosition, viewportSize);
304
- const viewEnd = viewStart + viewportSize;
305
- const contained = start >= viewStart && end <= viewEnd;
306
- const spanning = start <= viewStart && end >= viewEnd;
307
- if (contained || spanning) {
308
- return null;
309
- }
310
- return start < viewStart ? "start" : "end";
379
+ return this.getAlignedScrollOffset(index, viewportSize, position === "center" || position === "end" ? position : "start");
311
380
  }
312
381
  /** Returns the visible and over-scanned item range for the given scroll state. */
313
382
  getVisibleRange(scrollPosition, viewportSize, overScan) {
@@ -349,6 +418,25 @@ class VirtualScrollEngine {
349
418
  const clamped = clamp(index, 0, this._tree.length - 1);
350
419
  return [this._tree.prefixSum(clamped), this._tree.prefixSum(clamped + 1)];
351
420
  }
421
+ /**
422
+ * Records a changed configured estimate and starts a new sample. Returns
423
+ * false for an unchanged one, which keeps the adapted average.
424
+ */
425
+ _configureEstimate(estimatedSize) {
426
+ if (estimatedSize === this._configuredEstimate) {
427
+ return false;
428
+ }
429
+ this._configuredEstimate = estimatedSize;
430
+ this._tree?.startSample();
431
+ return true;
432
+ }
433
+ _applyEstimate(estimatedSize) {
434
+ if (!this._tree || this._tree.estimate === estimatedSize) {
435
+ return;
436
+ }
437
+ this._tree.estimate = estimatedSize;
438
+ this._invalidate();
439
+ }
352
440
  _invalidate() {
353
441
  this._version.update((v) => v + 1);
354
442
  }
@@ -366,7 +454,14 @@ class VirtualScrollEngine {
366
454
  */
367
455
  _toVirtual(domOffset, viewportSize) {
368
456
  const [virtualRange, domRange] = this._scrollRanges(viewportSize);
369
- return (Math.max(0, domOffset) * virtualRange) / domRange;
457
+ // A shrunken track's scroll event lags a frame, so clamp the stale offset now.
458
+ // E.g. 10 x 50px items, 300px viewport, stale 1000px: items 4-9, not 9 alone.
459
+ const reachable = clamp(domOffset, 0, this._maxDomOffset(viewportSize));
460
+ return (reachable * virtualRange) / domRange;
461
+ }
462
+ /** The largest DOM scroll offset for a `viewportSize` px viewport. */
463
+ _maxDomOffset(viewportSize) {
464
+ return Math.max(0, this.domSize() - viewportSize);
370
465
  }
371
466
  /**
372
467
  * The virtual and DOM scroll ranges to map between; the identity while the
@@ -435,14 +530,219 @@ class IgxVirtualItemDirective {
435
530
  constructor() {
436
531
  this.template = inject(TemplateRef);
437
532
  }
438
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: IgxVirtualItemDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
439
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.7", type: IgxVirtualItemDirective, isStandalone: true, selector: "ng-template[igxVirtualItem]", ngImport: i0 }); }
533
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.2.0", ngImport: i0, type: IgxVirtualItemDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
534
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.2.0", type: IgxVirtualItemDirective, isStandalone: true, selector: "ng-template[igxVirtualItem]", ngImport: i0 }); }
440
535
  }
441
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: IgxVirtualItemDirective, decorators: [{
536
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.2.0", ngImport: i0, type: IgxVirtualItemDirective, decorators: [{
442
537
  type: Directive,
443
538
  args: [{ selector: "ng-template[igxVirtualItem]" }]
444
539
  }] });
445
540
 
541
+ /**
542
+ * Weights for choosing the views that stay in place. A recycled view needs a
543
+ * new layout anyway, so kept views move only when fewer than half the recycled
544
+ * ones.
545
+ */
546
+ const KEPT_WEIGHT = 2;
547
+ const RECYCLED_WEIGHT = 1;
548
+ /**
549
+ * Marks the entries of the increasing subsequence of `values` with the largest
550
+ * total weight. The values are distinct integers in `[0, size)`, or negative
551
+ * for the entries to skip. O(n log size).
552
+ */
553
+ function heaviestIncreasingSubsequence(values, weights, size) {
554
+ // Fenwick tree over values: each node holds the heaviest weight ending in
555
+ // its range, and that entry.
556
+ const heaviest = new Int32Array(size + 1);
557
+ const endsAt = new Int32Array(size + 1);
558
+ const previous = new Int32Array(values.length);
559
+ const marked = new Uint8Array(values.length);
560
+ let best = 0;
561
+ let last = -1;
562
+ for (let i = 0; i < values.length; i++) {
563
+ if (values[i] < 0) {
564
+ continue;
565
+ }
566
+ let total = 0;
567
+ let before = -1;
568
+ for (let node = values[i]; node > 0; node -= node & -node) {
569
+ if (heaviest[node] > total) {
570
+ total = heaviest[node];
571
+ before = endsAt[node];
572
+ }
573
+ }
574
+ total += weights[i];
575
+ previous[i] = before;
576
+ for (let node = values[i] + 1; node <= size; node += node & -node) {
577
+ if (total > heaviest[node]) {
578
+ heaviest[node] = total;
579
+ endsAt[node] = i;
580
+ }
581
+ }
582
+ if (total > best) {
583
+ best = total;
584
+ last = i;
585
+ }
586
+ }
587
+ for (let i = last; i >= 0; i = previous[i]) {
588
+ marked[i] = 1;
589
+ }
590
+ return marked;
591
+ }
592
+ /**
593
+ * @hidden @internal
594
+ *
595
+ * Like `@for`, but hands the view of a leaving key to an entering key instead
596
+ * of destroying and recreating it, which avoids DOM churn on each scroll step.
597
+ * Reused views keep DOM state the template does not bind.
598
+ *
599
+ * ```
600
+ * keys 0 1 2 3 2 3 4 5 a scroll by two items
601
+ * views A B C D -> C D A B C, D kept; A, B recycled
602
+ * ```
603
+ *
604
+ * Untaken views are destroyed, not pooled: content queries still report
605
+ * components in detached views.
606
+ */
607
+ class IgxVsRecycleDirective {
608
+ constructor() {
609
+ this._container = inject(ViewContainerRef);
610
+ this._template = inject(TemplateRef);
611
+ /** The items to render, in order. */
612
+ this.igxVsRecycleOf = input.required(/* @ts-ignore */
613
+ ...(ngDevMode ? [{ debugName: "igxVsRecycleOf" }] : /* istanbul ignore next */ []));
614
+ /** Returns the key of an item. */
615
+ this.igxVsRecycleKey = input.required(/* @ts-ignore */
616
+ ...(ngDevMode ? [{ debugName: "igxVsRecycleKey" }] : /* istanbul ignore next */ []));
617
+ /** The rendered views and their keys, in DOM order. */
618
+ this._views = [];
619
+ this._keys = [];
620
+ }
621
+ static ngTemplateContextGuard(_directive, _context) {
622
+ return true;
623
+ }
624
+ ngOnChanges() {
625
+ const items = this.igxVsRecycleOf();
626
+ const keys = items.map(this.igxVsRecycleKey());
627
+ this._views = this._reconcile(items, keys);
628
+ this._keys = keys;
629
+ }
630
+ /**
631
+ * Gives each key a view, orders the views by key, and sets their items.
632
+ *
633
+ * 1. A staying key keeps its view.
634
+ * 2. Other keys take the leftover views in DOM order, so a full replacement
635
+ * moves no DOM. Views are created when those run out; untaken ones are
636
+ * destroyed.
637
+ * 3. The heaviest subsequence of reused views that keeps its order stays put
638
+ * (see `KEPT_WEIGHT`). The focused view outweighs all others, so it keeps
639
+ * focus. Each other view moves in front of its successor.
640
+ */
641
+ _reconcile(items, keys) {
642
+ const oldViews = this._views;
643
+ // Hydration hands out the server-rendered DOM in creation order.
644
+ if (oldViews.length === 0) {
645
+ return items.map((item) => this._container.createEmbeddedView(this._template, { $implicit: item }));
646
+ }
647
+ const count = keys.length;
648
+ const focused = this._focusedPosition();
649
+ // The old DOM position (-1 for a new view) and the weight of the view at
650
+ // each new position.
651
+ const from = new Int32Array(count).fill(-1);
652
+ const weights = new Int32Array(count);
653
+ const claimed = new Uint8Array(oldViews.length);
654
+ // The first old position of each key, so duplicate keys keep one view.
655
+ const oldPositions = new Map();
656
+ for (let i = this._keys.length - 1; i >= 0; i--) {
657
+ oldPositions.set(this._keys[i], i);
658
+ }
659
+ for (let i = 0; i < count; i++) {
660
+ const position = oldPositions.get(keys[i]);
661
+ if (position === undefined || claimed[position]) {
662
+ continue;
663
+ }
664
+ claimed[position] = 1;
665
+ from[i] = position;
666
+ weights[i] = position === focused ? KEPT_WEIGHT * count : KEPT_WEIGHT;
667
+ }
668
+ let spare = 0;
669
+ for (let i = 0; i < count && spare < oldViews.length; i++) {
670
+ if (from[i] >= 0) {
671
+ continue;
672
+ }
673
+ while (spare < oldViews.length && claimed[spare]) {
674
+ spare++;
675
+ }
676
+ if (spare < oldViews.length) {
677
+ claimed[spare] = 1;
678
+ from[i] = spare;
679
+ weights[i] = RECYCLED_WEIGHT;
680
+ }
681
+ }
682
+ oldViews.forEach((view, position) => {
683
+ if (!claimed[position]) {
684
+ view.destroy();
685
+ }
686
+ });
687
+ const stable = heaviestIncreasingSubsequence(from, weights, oldViews.length);
688
+ const views = new Array(count);
689
+ let next;
690
+ // Walk backwards, so the successor of each view is already in place.
691
+ for (let i = count - 1; i >= 0; i--) {
692
+ let view = from[i] < 0 ? undefined : oldViews[from[i]];
693
+ if (view) {
694
+ if (!stable[i]) {
695
+ this._moveBefore(view, next);
696
+ }
697
+ view.context.$implicit = items[i];
698
+ }
699
+ else {
700
+ view = this._container.createEmbeddedView(this._template, { $implicit: items[i] }, next ? this._container.indexOf(next) : undefined);
701
+ }
702
+ views[i] = view;
703
+ next = view;
704
+ }
705
+ return views;
706
+ }
707
+ /** Moves `view` in front of `next`, or to the end without one. */
708
+ _moveBefore(view, next) {
709
+ const current = this._container.indexOf(view);
710
+ const target = next
711
+ ? this._container.indexOf(next)
712
+ : this._container.length;
713
+ if (current === target - 1) {
714
+ return;
715
+ }
716
+ // `move` removes the view before it inserts it, which shifts the later
717
+ // indices down by one.
718
+ this._container.move(view, current < target ? target - 1 : target);
719
+ }
720
+ /** The position in `_views` of the view that holds the focus, or -1. */
721
+ _focusedPosition() {
722
+ const parent = this._container.element.nativeElement.parentNode;
723
+ // Angular's server DOM has no getRootNode, and nothing has focus there.
724
+ if (typeof parent?.getRootNode !== "function") {
725
+ return -1;
726
+ }
727
+ // The root, not the document: the list can render in a shadow root.
728
+ const root = parent.getRootNode();
729
+ let node = root.activeElement ?? null;
730
+ while (node && node.parentNode !== parent) {
731
+ node = node.parentNode;
732
+ }
733
+ if (!node) {
734
+ return -1;
735
+ }
736
+ return this._views.findIndex((view) => view.rootNodes.includes(node));
737
+ }
738
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.2.0", ngImport: i0, type: IgxVsRecycleDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
739
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.2.0", type: IgxVsRecycleDirective, isStandalone: true, selector: "[igxVsRecycle][igxVsRecycleOf]", inputs: { igxVsRecycleOf: { classPropertyName: "igxVsRecycleOf", publicName: "igxVsRecycleOf", isSignal: true, isRequired: true, transformFunction: null }, igxVsRecycleKey: { classPropertyName: "igxVsRecycleKey", publicName: "igxVsRecycleKey", isSignal: true, isRequired: true, transformFunction: null } }, usesOnChanges: true, ngImport: i0 }); }
740
+ }
741
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.2.0", ngImport: i0, type: IgxVsRecycleDirective, decorators: [{
742
+ type: Directive,
743
+ args: [{ selector: "[igxVsRecycle][igxVsRecycleOf]" }]
744
+ }], propDecorators: { igxVsRecycleOf: [{ type: i0.Input, args: [{ isSignal: true, alias: "igxVsRecycleOf", required: true }] }], igxVsRecycleKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "igxVsRecycleKey", required: true }] }] } });
745
+
446
746
  /** Defaults for the inputs, also used as the fallback for invalid values. */
447
747
  const DEFAULT_OVER_SCAN = 2;
448
748
  const DEFAULT_ESTIMATED_ITEM_SIZE = 50;
@@ -587,6 +887,7 @@ class IgxVirtualScrollComponent {
587
887
  /**
588
888
  * Estimated item size in pixels used before an item is measured in the DOM.
589
889
  * The engine replaces this with the actual measured size after the first render of each item.
890
+ * Unmeasured items then take the average measured size.
590
891
  * Default is 50 pixels.
591
892
  * Setting this to a value close to the actual average item size can improve initial rendering performance.
592
893
  */
@@ -640,9 +941,28 @@ class IgxVirtualScrollComponent {
640
941
  * infer an item's position from the markup. Templates that render a role
641
942
  * with set semantics (`listitem`, `option`, `row`, ...) should map the
642
943
  * context's `index` and `count` onto `aria-posinset` and `aria-setsize`.
944
+ *
945
+ * Item elements are recycled: bind all item state and write user changes
946
+ * back to the item, or unbound DOM state (e.g. a toggled checkbox) shows on
947
+ * another item. See `keyFunction`.
643
948
  */
644
949
  this.itemTemplate = input(null, /* @ts-ignore */
645
950
  ...(ngDevMode ? [{ debugName: "itemTemplate" }] : /* istanbul ignore next */ []));
951
+ /**
952
+ * Returns a unique key from an item and its index in the whole collection. An item keeps its
953
+ * element while its key stays rendered. Defaults to the index; set it when items move within
954
+ * `data` (sort, insert, remove).
955
+ *
956
+ * @example
957
+ * ```html
958
+ * <igx-virtual-scroll [data]="people" [keyFunction]="byId">...</igx-virtual-scroll>
959
+ * ```
960
+ * ```ts
961
+ * byId = (person: Person) => person.id;
962
+ * ```
963
+ */
964
+ this.keyFunction = input(null, /* @ts-ignore */
965
+ ...(ngDevMode ? [{ debugName: "keyFunction" }] : /* istanbul ignore next */ []));
646
966
  //#endregion
647
967
  //#region Public outputs
648
968
  /** Emitted when the rendered virtual window changes. */
@@ -726,6 +1046,14 @@ class IgxVirtualScrollComponent {
726
1046
  endIndex: Math.min(endIndex, from + items.length - 1),
727
1047
  };
728
1048
  }, { ...(ngDevMode ? { debugName: "_loadedRange" } : /* istanbul ignore next */ {}), equal: rangesEqual });
1049
+ /** The key of each rendered item: from `keyFunction`, or its index. */
1050
+ this._itemKey = computed(() => {
1051
+ const keyOf = this.keyFunction();
1052
+ return keyOf
1053
+ ? (context) => keyOf(context.$implicit, context.index)
1054
+ : (context) => context.index;
1055
+ }, /* @ts-ignore */
1056
+ ...(ngDevMode ? [{ debugName: "_itemKey" }] : /* istanbul ignore next */ []));
729
1057
  /** The item contexts for the currently rendered window, in render order. */
730
1058
  this._renderedItems = computed(() => {
731
1059
  const { startIndex, endIndex } = this._loadedRange();
@@ -768,6 +1096,10 @@ class IgxVirtualScrollComponent {
768
1096
  untracked(() => {
769
1097
  const previous = this._previousItems;
770
1098
  const retained = this._retainCount(previous, loaded);
1099
+ // Data that grew past the last request answered it, so a later reset may ask again.
1100
+ if (loaded.items.length > this._lastDataRequestIndex) {
1101
+ this._lastDataRequestIndex = -1;
1102
+ }
771
1103
  this._previousItems = loaded;
772
1104
  this._engine.resize(loaded.totalCount, this._normalizedItemSize(), retained);
773
1105
  this._remeasureFrom(retained);
@@ -782,15 +1114,16 @@ class IgxVirtualScrollComponent {
782
1114
  const size = this._normalizedItemSize();
783
1115
  untracked(() => this._engine.updateEstimatedSize(size));
784
1116
  });
785
- // The scroll offset of the previous axis does not carry over.
1117
+ // The sizes and scroll offset of the previous axis do not carry over.
786
1118
  effect(() => {
787
1119
  this.orientation();
788
1120
  untracked(() => {
789
1121
  if (!this._isBrowser) {
790
1122
  return;
791
1123
  }
792
- // The size of the previous axis says nothing about the new one.
793
1124
  this._viewportSize.set(null);
1125
+ this._engine.clearSizes();
1126
+ this._remeasureFrom(0);
794
1127
  this._measureViewport();
795
1128
  this._scrollPosition = this._currentAxisScroll();
796
1129
  this._scrollTick.update((v) => v + 1);
@@ -841,11 +1174,13 @@ class IgxVirtualScrollComponent {
841
1174
  * first jump can miss the target. The items at the landing point are then
842
1175
  * measured and the scroll position is corrected, until the offset is
843
1176
  * stable. The returned promise resolves on that final offset; callers that
844
- * need only the first, approximate scroll can ignore it.
1177
+ * need only the first, approximate scroll can ignore it, because the first
1178
+ * jump is applied before the call returns.
845
1179
  *
846
1180
  * @param index The index of the item to scroll to.
847
1181
  * @param options `block` / `inline` select the alignment (`start`,
848
- * `center`, `end` or `nearest`); `behavior` selects `auto` or `smooth`.
1182
+ * `center`, `end` or `nearest`, which scrolls the smallest distance that
1183
+ * brings the item into view); `behavior` selects `auto` or `smooth`.
849
1184
  */
850
1185
  async scrollToIndex(index, options) {
851
1186
  const clampedIndex = clamp(index, 0, Math.max(0, this._loaded().totalCount - 1));
@@ -903,24 +1238,12 @@ class IgxVirtualScrollComponent {
903
1238
  * The scroll offset that aligns `index` in the viewport according to
904
1239
  * `options`, from the engine's current size data. As more items are
905
1240
  * measured, the same input can give a different, more accurate result.
906
- *
907
- * `nearest` keeps the current offset for an item already in view and
908
- * otherwise brings the item to its nearer edge, as native `scrollIntoView`
909
- * does.
910
1241
  */
911
1242
  _getAlignedScrollOffset(index, options) {
912
- const requested = this._isVertical()
1243
+ const position = this._isVertical()
913
1244
  ? (options?.block ?? "start")
914
1245
  : (options?.inline ?? options?.block ?? "start");
915
- const current = this._currentAxisScroll();
916
- const viewport = this._effectiveViewportSize();
917
- const align = requested === "nearest"
918
- ? this._engine.getNearestAlignment(index, current, viewport)
919
- : requested;
920
- if (align === null) {
921
- return current;
922
- }
923
- return this._engine.getAlignedScrollOffset(index, viewport, align);
1246
+ return this._engine.resolveScrollOffset(index, this._currentAxisScroll(), this._effectiveViewportSize(), position);
924
1247
  }
925
1248
  /**
926
1249
  * Applies a scroll offset to the active axis and waits for the scroll,
@@ -932,8 +1255,13 @@ class IgxVirtualScrollComponent {
932
1255
  * mid-scroll.
933
1256
  */
934
1257
  _scrollAndWaitForEnd(offset, behavior) {
935
- if (!this._isBrowser ||
936
- Math.abs(this._currentAxisScroll() - offset) < SCROLL_OFFSET_EPSILON_PX) {
1258
+ if (!this._isBrowser) {
1259
+ return Promise.resolve();
1260
+ }
1261
+ if (Math.abs(this._currentAxisScroll() - offset) < SCROLL_OFFSET_EPSILON_PX) {
1262
+ // Already there, but the window may lag: a re-attached host got there without a scroll
1263
+ // event, and the event for a scroll that was just applied may still be pending.
1264
+ this._syncScrollPosition();
937
1265
  return Promise.resolve();
938
1266
  }
939
1267
  return this._withDeadline(SCROLL_END_TIMEOUT_MS, (abort) => {
@@ -1032,11 +1360,16 @@ class IgxVirtualScrollComponent {
1032
1360
  }
1033
1361
  //#endregion
1034
1362
  //#region Measurement
1363
+ /** Whether the host has a box, as opposed to being hidden or detached. */
1364
+ _isLaidOut() {
1365
+ const host = this._hostRef.nativeElement;
1366
+ return host.isConnected && host.getClientRects().length > 0;
1367
+ }
1035
1368
  _measureViewport() {
1036
1369
  const host = this._hostRef.nativeElement;
1037
1370
  // A host with no box is hidden or detached, not sized: its last measurement is kept so
1038
1371
  // it renders in the pass that reveals it. A laid-out zero is a size like any other.
1039
- if (!host.isConnected || host.getClientRects().length === 0) {
1372
+ if (!this._isLaidOut()) {
1040
1373
  return;
1041
1374
  }
1042
1375
  const size = this._isVertical() ? host.clientHeight : host.clientWidth;
@@ -1047,7 +1380,12 @@ class IgxVirtualScrollComponent {
1047
1380
  _setupViewportResizeObserver() {
1048
1381
  this._viewportResizeObserver?.disconnect();
1049
1382
  this._zone.runOutsideAngular(() => {
1050
- this._viewportResizeObserver = new ResizeObserver(() => this._measureViewport());
1383
+ this._viewportResizeObserver = new ResizeObserver(() => {
1384
+ this._measureViewport();
1385
+ // Detaching the host resets its scroll position without a scroll event. Following it
1386
+ // while detached renders the window the host comes back with, so it does not show blank.
1387
+ this._syncScrollPosition();
1388
+ });
1051
1389
  this._viewportResizeObserver.observe(this._hostRef.nativeElement);
1052
1390
  });
1053
1391
  }
@@ -1073,6 +1411,18 @@ class IgxVirtualScrollComponent {
1073
1411
  this._scrollTick.update((v) => v + 1);
1074
1412
  }
1075
1413
  }
1414
+ /**
1415
+ * Re-reads the scroll offset outside a scroll event. Detaching the host resets the offset
1416
+ * without one, and the event for a scroll that was just applied may not have arrived yet.
1417
+ */
1418
+ _syncScrollPosition() {
1419
+ // A hidden host reads 0 but gets its offset back when it is shown again. A detached one
1420
+ // reads the 0 it is re-attached with, so it is not skipped.
1421
+ if (this._hostRef.nativeElement.isConnected && !this._isLaidOut()) {
1422
+ return;
1423
+ }
1424
+ this._handleScroll();
1425
+ }
1076
1426
  _handleItemResize(entries) {
1077
1427
  for (const entry of entries) {
1078
1428
  const index = itemIndex(entry.target);
@@ -1086,16 +1436,18 @@ class IgxVirtualScrollComponent {
1086
1436
  this._engine.measureItem(index, measured);
1087
1437
  }
1088
1438
  }
1439
+ // Unmeasured items follow the measured average.
1440
+ this._engine.adaptEstimate(untracked(this._loadedRange).startIndex);
1089
1441
  }
1090
1442
  /**
1091
1443
  * Synchronizes the item observer with the rendered window, applying only
1092
1444
  * the difference. A newly observed element gets one initial measurement.
1093
1445
  *
1094
1446
  * An element whose `data-index` changed is re-registered, because
1095
- * `observe` on an already observed element is a no-op. `@for` tracks by
1096
- * slot and reuses the wrapper elements, so after a scroll the same element
1097
- * can host a different item at an identical size. The observer stays quiet
1098
- * about that and the new index would keep its estimated size.
1447
+ * `observe` on an already observed element is a no-op. The wrapper elements
1448
+ * are recycled, so after a scroll the same element can host a different
1449
+ * item at an identical size. The observer stays quiet about that and the
1450
+ * new index would keep its estimated size.
1099
1451
  */
1100
1452
  _scheduleItemMeasurement() {
1101
1453
  const content = this._contentDivRef()?.nativeElement;
@@ -1124,7 +1476,7 @@ class IgxVirtualScrollComponent {
1124
1476
  }
1125
1477
  /**
1126
1478
  * Forgets the wrappers from `index` on, so the next pass registers them
1127
- * again: the resize dropped their sizes, and a wrapper whose size did not
1479
+ * again: the engine dropped their sizes, and a wrapper whose size did not
1128
1480
  * change reports nothing on its own.
1129
1481
  */
1130
1482
  _remeasureFrom(index) {
@@ -1237,17 +1589,17 @@ class IgxVirtualScrollComponent {
1237
1589
  this._itemResizeObserver = null;
1238
1590
  this._observedItems.clear();
1239
1591
  }
1240
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: IgxVirtualScrollComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
1241
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: IgxVirtualScrollComponent, isStandalone: true, selector: "igx-virtual-scroll", inputs: { data: { classPropertyName: "data", publicName: "data", isSignal: true, isRequired: false, transformFunction: null }, orientation: { classPropertyName: "orientation", publicName: "orientation", isSignal: true, isRequired: false, transformFunction: null }, overScan: { classPropertyName: "overScan", publicName: "overScan", isSignal: true, isRequired: false, transformFunction: null }, estimatedItemSize: { classPropertyName: "estimatedItemSize", publicName: "estimatedItemSize", isSignal: true, isRequired: false, transformFunction: null }, dataWindow: { classPropertyName: "dataWindow", publicName: "dataWindow", isSignal: true, isRequired: false, transformFunction: null }, initialViewportSize: { classPropertyName: "initialViewportSize", publicName: "initialViewportSize", isSignal: true, isRequired: false, transformFunction: null }, itemTemplate: { classPropertyName: "itemTemplate", publicName: "itemTemplate", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { stateChange: "stateChange", dataRequest: "dataRequest" }, host: { attributes: { "role": "list" }, properties: { "attr.data-orientation": "orientation()" }, classAttribute: "igx-virtual-scroll" }, queries: [{ propertyName: "_itemDirective", first: true, predicate: IgxVirtualItemDirective, descendants: true, isSignal: true }], viewQueries: [{ propertyName: "_contentDivRef", first: true, predicate: ["contentDiv"], descendants: true, isSignal: true }], ngImport: i0, template: "<div\n class=\"igx-virtual-scroll__track\"\n role=\"presentation\"\n [style.height.px]=\"_isVertical() ? _spaceSize() : null\"\n [style.width.px]=\"!_isVertical() ? _spaceSize() : null\"\n>\n <div\n class=\"igx-virtual-scroll__content\"\n #contentDiv\n role=\"presentation\"\n [style.transform]=\"_contentTransform()\"\n >\n @if (_resolvedTemplate(); as template) {\n @for (context of _renderedItems(); track $index) {\n <div\n class=\"igx-virtual-item\"\n role=\"presentation\"\n [attr.data-index]=\"context.index\"\n >\n <ng-container\n [ngTemplateOutlet]=\"template\"\n [ngTemplateOutletContext]=\"context\"\n />\n </div>\n }\n }\n </div>\n</div>\n", styles: ["@layer ig.base{.igx-virtual-scroll{display:block;position:relative;overflow:auto}.igx-virtual-scroll[data-orientation=vertical]{overflow-y:auto;overflow-x:hidden}.igx-virtual-scroll[data-orientation=horizontal]{overflow-x:auto;overflow-y:hidden}.igx-virtual-scroll__track{position:relative;width:100%;min-height:100%}.igx-virtual-scroll[data-orientation=horizontal] .igx-virtual-scroll__track{height:100%;width:auto;min-height:unset}.igx-virtual-scroll__content{position:absolute;top:0;left:0;width:100%;will-change:transform;contain:layout style paint}.igx-virtual-scroll[data-orientation=horizontal] .igx-virtual-scroll__content{display:flex;flex-direction:row;height:100%;width:auto}.igx-virtual-scroll[data-orientation=horizontal]:dir(rtl) .igx-virtual-scroll__content{left:auto;right:0}.igx-virtual-scroll[data-orientation=horizontal] .igx-virtual-item{flex-shrink:0;height:100%}}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None }); }
1592
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.2.0", ngImport: i0, type: IgxVirtualScrollComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
1593
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.2.0", type: IgxVirtualScrollComponent, isStandalone: true, selector: "igx-virtual-scroll", inputs: { data: { classPropertyName: "data", publicName: "data", isSignal: true, isRequired: false, transformFunction: null }, orientation: { classPropertyName: "orientation", publicName: "orientation", isSignal: true, isRequired: false, transformFunction: null }, overScan: { classPropertyName: "overScan", publicName: "overScan", isSignal: true, isRequired: false, transformFunction: null }, estimatedItemSize: { classPropertyName: "estimatedItemSize", publicName: "estimatedItemSize", isSignal: true, isRequired: false, transformFunction: null }, dataWindow: { classPropertyName: "dataWindow", publicName: "dataWindow", isSignal: true, isRequired: false, transformFunction: null }, initialViewportSize: { classPropertyName: "initialViewportSize", publicName: "initialViewportSize", isSignal: true, isRequired: false, transformFunction: null }, itemTemplate: { classPropertyName: "itemTemplate", publicName: "itemTemplate", isSignal: true, isRequired: false, transformFunction: null }, keyFunction: { classPropertyName: "keyFunction", publicName: "keyFunction", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { stateChange: "stateChange", dataRequest: "dataRequest" }, host: { attributes: { "role": "list" }, properties: { "attr.data-orientation": "orientation()" }, classAttribute: "igx-virtual-scroll" }, queries: [{ propertyName: "_itemDirective", first: true, predicate: IgxVirtualItemDirective, descendants: true, isSignal: true }], viewQueries: [{ propertyName: "_contentDivRef", first: true, predicate: ["contentDiv"], descendants: true, isSignal: true }], ngImport: i0, template: "<div\n class=\"igx-virtual-scroll__track\"\n role=\"presentation\"\n [style.height.px]=\"_isVertical() ? _spaceSize() : null\"\n [style.width.px]=\"!_isVertical() ? _spaceSize() : null\"\n>\n <div\n class=\"igx-virtual-scroll__content\"\n #contentDiv\n role=\"presentation\"\n [style.transform]=\"_contentTransform()\"\n >\n @if (_resolvedTemplate(); as template) {\n <div\n *igxVsRecycle=\"let context of _renderedItems(); key: _itemKey()\"\n class=\"igx-virtual-item\"\n role=\"presentation\"\n [attr.data-index]=\"context.index\"\n >\n <ng-container\n [ngTemplateOutlet]=\"template\"\n [ngTemplateOutletContext]=\"context\"\n />\n </div>\n }\n </div>\n</div>\n", styles: ["@layer ig.base{.igx-virtual-scroll{display:block;position:relative;overflow:auto}.igx-virtual-scroll[data-orientation=vertical]{overflow-y:auto;overflow-x:hidden}.igx-virtual-scroll[data-orientation=horizontal]{overflow-x:auto;overflow-y:hidden}.igx-virtual-scroll__track{position:relative;width:100%;min-height:100%}.igx-virtual-scroll[data-orientation=horizontal] .igx-virtual-scroll__track{height:100%;width:auto;min-height:unset}.igx-virtual-scroll__content{position:absolute;top:0;left:0;width:100%;will-change:transform;contain:layout style paint}.igx-virtual-scroll[data-orientation=horizontal] .igx-virtual-scroll__content{display:flex;flex-direction:row;height:100%;width:auto}.igx-virtual-scroll[data-orientation=horizontal]:dir(rtl) .igx-virtual-scroll__content{left:auto;right:0}.igx-virtual-scroll[data-orientation=horizontal] .igx-virtual-item{flex-shrink:0;height:100%}}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: IgxVsRecycleDirective, selector: "[igxVsRecycle][igxVsRecycleOf]", inputs: ["igxVsRecycleOf", "igxVsRecycleKey"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None }); }
1242
1594
  }
1243
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: IgxVirtualScrollComponent, decorators: [{
1595
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.2.0", ngImport: i0, type: IgxVirtualScrollComponent, decorators: [{
1244
1596
  type: Component,
1245
- args: [{ selector: "igx-virtual-scroll", changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, imports: [NgTemplateOutlet], host: {
1597
+ args: [{ selector: "igx-virtual-scroll", changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, imports: [NgTemplateOutlet, IgxVsRecycleDirective], host: {
1246
1598
  class: "igx-virtual-scroll",
1247
1599
  role: "list",
1248
1600
  "[attr.data-orientation]": "orientation()",
1249
- }, template: "<div\n class=\"igx-virtual-scroll__track\"\n role=\"presentation\"\n [style.height.px]=\"_isVertical() ? _spaceSize() : null\"\n [style.width.px]=\"!_isVertical() ? _spaceSize() : null\"\n>\n <div\n class=\"igx-virtual-scroll__content\"\n #contentDiv\n role=\"presentation\"\n [style.transform]=\"_contentTransform()\"\n >\n @if (_resolvedTemplate(); as template) {\n @for (context of _renderedItems(); track $index) {\n <div\n class=\"igx-virtual-item\"\n role=\"presentation\"\n [attr.data-index]=\"context.index\"\n >\n <ng-container\n [ngTemplateOutlet]=\"template\"\n [ngTemplateOutletContext]=\"context\"\n />\n </div>\n }\n }\n </div>\n</div>\n", styles: ["@layer ig.base{.igx-virtual-scroll{display:block;position:relative;overflow:auto}.igx-virtual-scroll[data-orientation=vertical]{overflow-y:auto;overflow-x:hidden}.igx-virtual-scroll[data-orientation=horizontal]{overflow-x:auto;overflow-y:hidden}.igx-virtual-scroll__track{position:relative;width:100%;min-height:100%}.igx-virtual-scroll[data-orientation=horizontal] .igx-virtual-scroll__track{height:100%;width:auto;min-height:unset}.igx-virtual-scroll__content{position:absolute;top:0;left:0;width:100%;will-change:transform;contain:layout style paint}.igx-virtual-scroll[data-orientation=horizontal] .igx-virtual-scroll__content{display:flex;flex-direction:row;height:100%;width:auto}.igx-virtual-scroll[data-orientation=horizontal]:dir(rtl) .igx-virtual-scroll__content{left:auto;right:0}.igx-virtual-scroll[data-orientation=horizontal] .igx-virtual-item{flex-shrink:0;height:100%}}\n"] }]
1250
- }], ctorParameters: () => [], propDecorators: { _itemDirective: [{ type: i0.ContentChild, args: [i0.forwardRef(() => IgxVirtualItemDirective), { isSignal: true }] }], _contentDivRef: [{ type: i0.ViewChild, args: ["contentDiv", { isSignal: true }] }], data: [{ type: i0.Input, args: [{ isSignal: true, alias: "data", required: false }] }], orientation: [{ type: i0.Input, args: [{ isSignal: true, alias: "orientation", required: false }] }], overScan: [{ type: i0.Input, args: [{ isSignal: true, alias: "overScan", required: false }] }], estimatedItemSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "estimatedItemSize", required: false }] }], dataWindow: [{ type: i0.Input, args: [{ isSignal: true, alias: "dataWindow", required: false }] }], initialViewportSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "initialViewportSize", required: false }] }], itemTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemTemplate", required: false }] }], stateChange: [{ type: i0.Output, args: ["stateChange"] }], dataRequest: [{ type: i0.Output, args: ["dataRequest"] }] } });
1601
+ }, template: "<div\n class=\"igx-virtual-scroll__track\"\n role=\"presentation\"\n [style.height.px]=\"_isVertical() ? _spaceSize() : null\"\n [style.width.px]=\"!_isVertical() ? _spaceSize() : null\"\n>\n <div\n class=\"igx-virtual-scroll__content\"\n #contentDiv\n role=\"presentation\"\n [style.transform]=\"_contentTransform()\"\n >\n @if (_resolvedTemplate(); as template) {\n <div\n *igxVsRecycle=\"let context of _renderedItems(); key: _itemKey()\"\n class=\"igx-virtual-item\"\n role=\"presentation\"\n [attr.data-index]=\"context.index\"\n >\n <ng-container\n [ngTemplateOutlet]=\"template\"\n [ngTemplateOutletContext]=\"context\"\n />\n </div>\n }\n </div>\n</div>\n", styles: ["@layer ig.base{.igx-virtual-scroll{display:block;position:relative;overflow:auto}.igx-virtual-scroll[data-orientation=vertical]{overflow-y:auto;overflow-x:hidden}.igx-virtual-scroll[data-orientation=horizontal]{overflow-x:auto;overflow-y:hidden}.igx-virtual-scroll__track{position:relative;width:100%;min-height:100%}.igx-virtual-scroll[data-orientation=horizontal] .igx-virtual-scroll__track{height:100%;width:auto;min-height:unset}.igx-virtual-scroll__content{position:absolute;top:0;left:0;width:100%;will-change:transform;contain:layout style paint}.igx-virtual-scroll[data-orientation=horizontal] .igx-virtual-scroll__content{display:flex;flex-direction:row;height:100%;width:auto}.igx-virtual-scroll[data-orientation=horizontal]:dir(rtl) .igx-virtual-scroll__content{left:auto;right:0}.igx-virtual-scroll[data-orientation=horizontal] .igx-virtual-item{flex-shrink:0;height:100%}}\n"] }]
1602
+ }], ctorParameters: () => [], propDecorators: { _itemDirective: [{ type: i0.ContentChild, args: [i0.forwardRef(() => IgxVirtualItemDirective), { isSignal: true }] }], _contentDivRef: [{ type: i0.ViewChild, args: ["contentDiv", { isSignal: true }] }], data: [{ type: i0.Input, args: [{ isSignal: true, alias: "data", required: false }] }], orientation: [{ type: i0.Input, args: [{ isSignal: true, alias: "orientation", required: false }] }], overScan: [{ type: i0.Input, args: [{ isSignal: true, alias: "overScan", required: false }] }], estimatedItemSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "estimatedItemSize", required: false }] }], dataWindow: [{ type: i0.Input, args: [{ isSignal: true, alias: "dataWindow", required: false }] }], initialViewportSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "initialViewportSize", required: false }] }], itemTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemTemplate", required: false }] }], keyFunction: [{ type: i0.Input, args: [{ isSignal: true, alias: "keyFunction", required: false }] }], stateChange: [{ type: i0.Output, args: ["stateChange"] }], dataRequest: [{ type: i0.Output, args: ["dataRequest"] }] } });
1251
1603
 
1252
1604
  /**
1253
1605
  * Generated bundle index. Do not edit.