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.
- package/README.md +1 -1
- package/button-group/README.md +42 -9
- package/calendar/README.md +30 -20
- package/card/README.md +1 -1
- package/fesm2022/igniteui-angular-accordion.mjs +7 -7
- package/fesm2022/igniteui-angular-action-strip.mjs +11 -22
- package/fesm2022/igniteui-angular-action-strip.mjs.map +1 -1
- package/fesm2022/igniteui-angular-avatar.mjs +7 -7
- package/fesm2022/igniteui-angular-badge.mjs +7 -7
- package/fesm2022/igniteui-angular-banner.mjs +10 -10
- package/fesm2022/igniteui-angular-bottom-nav.mjs +22 -22
- package/fesm2022/igniteui-angular-button-group.mjs +32 -35
- package/fesm2022/igniteui-angular-button-group.mjs.map +1 -1
- package/fesm2022/igniteui-angular-calendar.mjs +132 -180
- package/fesm2022/igniteui-angular-calendar.mjs.map +1 -1
- package/fesm2022/igniteui-angular-card.mjs +47 -58
- package/fesm2022/igniteui-angular-card.mjs.map +1 -1
- package/fesm2022/igniteui-angular-carousel.mjs +22 -22
- package/fesm2022/igniteui-angular-chat-extras.mjs +6 -6
- package/fesm2022/igniteui-angular-chat.mjs +12 -12
- package/fesm2022/igniteui-angular-checkbox.mjs +7 -7
- package/fesm2022/igniteui-angular-chips.mjs +10 -10
- package/fesm2022/igniteui-angular-combo.mjs +66 -60
- package/fesm2022/igniteui-angular-combo.mjs.map +1 -1
- package/fesm2022/igniteui-angular-core.mjs +107 -79
- package/fesm2022/igniteui-angular-core.mjs.map +1 -1
- package/fesm2022/igniteui-angular-date-picker.mjs +38 -38
- package/fesm2022/igniteui-angular-dialog.mjs +13 -13
- package/fesm2022/igniteui-angular-directives.mjs +194 -194
- package/fesm2022/igniteui-angular-drop-down.mjs +29 -29
- package/fesm2022/igniteui-angular-expansion-panel.mjs +28 -28
- package/fesm2022/igniteui-angular-grids-core.mjs +737 -661
- package/fesm2022/igniteui-angular-grids-core.mjs.map +1 -1
- package/fesm2022/igniteui-angular-grids-grid.mjs +69 -58
- package/fesm2022/igniteui-angular-grids-grid.mjs.map +1 -1
- package/fesm2022/igniteui-angular-grids-hierarchical-grid.mjs +37 -37
- package/fesm2022/igniteui-angular-grids-lite.mjs +25 -17
- package/fesm2022/igniteui-angular-grids-lite.mjs.map +1 -1
- package/fesm2022/igniteui-angular-grids-pivot-grid.mjs +92 -81
- package/fesm2022/igniteui-angular-grids-pivot-grid.mjs.map +1 -1
- package/fesm2022/igniteui-angular-grids-tree-grid.mjs +55 -55
- package/fesm2022/igniteui-angular-icon.mjs +10 -10
- package/fesm2022/igniteui-angular-input-group.mjs +25 -25
- package/fesm2022/igniteui-angular-list.mjs +40 -40
- package/fesm2022/igniteui-angular-navbar.mjs +13 -13
- package/fesm2022/igniteui-angular-navigation-drawer.mjs +43 -38
- package/fesm2022/igniteui-angular-navigation-drawer.mjs.map +1 -1
- package/fesm2022/igniteui-angular-paginator.mjs +19 -19
- package/fesm2022/igniteui-angular-progressbar.mjs +19 -19
- package/fesm2022/igniteui-angular-query-builder.mjs +22 -22
- package/fesm2022/igniteui-angular-radio.mjs +25 -21
- package/fesm2022/igniteui-angular-radio.mjs.map +1 -1
- package/fesm2022/igniteui-angular-select.mjs +25 -25
- package/fesm2022/igniteui-angular-simple-combo.mjs +9 -9
- package/fesm2022/igniteui-angular-simple-combo.mjs.map +1 -1
- package/fesm2022/igniteui-angular-slider.mjs +28 -28
- package/fesm2022/igniteui-angular-snackbar.mjs +7 -7
- package/fesm2022/igniteui-angular-splitter.mjs +13 -13
- package/fesm2022/igniteui-angular-stepper.mjs +34 -34
- package/fesm2022/igniteui-angular-switch.mjs +7 -7
- package/fesm2022/igniteui-angular-tabs.mjs +34 -34
- package/fesm2022/igniteui-angular-time-picker.mjs +19 -19
- package/fesm2022/igniteui-angular-toast.mjs +7 -7
- package/fesm2022/igniteui-angular-tree.mjs +28 -28
- package/fesm2022/igniteui-angular-virtual-scroll.mjs +497 -145
- package/fesm2022/igniteui-angular-virtual-scroll.mjs.map +1 -1
- package/migrations/common/UpdateChanges.d.ts +25 -5
- package/migrations/common/UpdateChanges.js +218 -34
- package/migrations/common/UpdateChanges.spec.js +763 -0
- package/migrations/migration-collection.json +1 -1
- package/migrations/update-22_2_0/index.js +145 -0
- package/migrations/update-22_2_0/index.spec.js +133 -0
- package/navigation-drawer/README.md +1 -1
- package/package.json +4 -4
- package/schematics/tsconfig.tsbuildinfo +1 -1
- package/skills/igniteui-angular-components/SKILL.md +9 -5
- package/skills/igniteui-angular-components/references/form-controls.md +1 -1
- package/skills/igniteui-angular-components/references/mcp-setup.md +14 -2
- package/skills/igniteui-angular-figma-to-app/SKILL.md +112 -525
- package/skills/igniteui-angular-figma-to-app/references/asset-extraction.md +49 -75
- package/skills/igniteui-angular-figma-to-app/references/design-provenance.md +201 -0
- package/skills/igniteui-angular-figma-to-app/references/design-token-bridge.md +175 -52
- package/skills/igniteui-angular-figma-to-app/references/figma-component-map.md +153 -99
- package/skills/igniteui-angular-figma-to-app/references/figma-exploration.md +226 -0
- package/skills/igniteui-angular-figma-to-app/references/mcp-setup.md +71 -105
- package/skills/igniteui-angular-figma-to-app/references/project-setup.md +63 -0
- package/skills/igniteui-angular-figma-to-app/references/theme-generation.md +120 -0
- package/skills/igniteui-angular-figma-to-app/references/validation-patterns.md +48 -50
- package/skills/igniteui-angular-generate-from-image-design/SKILL.md +11 -7
- package/skills/igniteui-angular-grids/SKILL.md +7 -3
- package/skills/igniteui-angular-grids/references/editing.md +1 -2
- package/skills/igniteui-angular-grids/references/grid-migration.md +1 -1
- package/skills/igniteui-angular-theming/SKILL.md +9 -5
- package/skills/igniteui-angular-theming/references/mcp-setup.md +12 -2
- package/types/igniteui-angular-button-group.d.ts +49 -34
- package/types/igniteui-angular-calendar.d.ts +33 -50
- package/types/igniteui-angular-card.d.ts +12 -17
- package/types/igniteui-angular-combo.d.ts +6 -0
- package/types/igniteui-angular-core.d.ts +10 -2
- package/types/igniteui-angular-grids-core.d.ts +48 -4
- package/types/igniteui-angular-grids-grid.d.ts +4 -0
- package/types/igniteui-angular-grids-lite.d.ts +5 -1
- package/types/igniteui-angular-grids-pivot-grid.d.ts +5 -1
- package/types/igniteui-angular-navigation-drawer.d.ts +1 -0
- package/types/igniteui-angular-radio.d.ts +5 -0
- package/types/igniteui-angular-virtual-scroll.d.ts +41 -13
- 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,
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
*/
|
|
37
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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.
|
|
60
|
-
this.
|
|
61
|
-
this.
|
|
62
|
-
this._topBit =
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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.
|
|
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.
|
|
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
|
|
91
|
-
*
|
|
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,
|
|
122
|
+
update(index, size) {
|
|
95
123
|
if (index < 0 || index >= this.length) {
|
|
96
124
|
return false;
|
|
97
125
|
}
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
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,
|
|
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
|
-
|
|
124
|
-
return SizeTree.
|
|
161
|
+
states.set(this._states.subarray(0, retained));
|
|
162
|
+
return new SizeTree(newLength, this.estimate, sizes, states);
|
|
125
163
|
}
|
|
126
164
|
/**
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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
|
-
|
|
134
|
-
let changed = false;
|
|
168
|
+
startSample() {
|
|
135
169
|
for (let i = 0; i < this.length; i++) {
|
|
136
|
-
if (
|
|
137
|
-
this.
|
|
138
|
-
changed = true;
|
|
170
|
+
if (this._states[i] === SAMPLED) {
|
|
171
|
+
this._states[i] = MEASURED;
|
|
139
172
|
}
|
|
140
173
|
}
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
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 -=
|
|
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
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
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 =
|
|
235
|
-
|
|
236
|
-
|
|
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.
|
|
252
|
-
this.
|
|
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,
|
|
362
|
+
return clamp(this._toDom(offset, viewportSize), 0, this._maxDomOffset(viewportSize));
|
|
289
363
|
}
|
|
290
364
|
/**
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
439
|
-
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
1243
|
+
const position = this._isVertical()
|
|
913
1244
|
? (options?.block ?? "start")
|
|
914
1245
|
: (options?.inline ?? options?.block ?? "start");
|
|
915
|
-
|
|
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
|
-
|
|
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 (!
|
|
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(() =>
|
|
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.
|
|
1096
|
-
*
|
|
1097
|
-
*
|
|
1098
|
-
*
|
|
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
|
|
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.
|
|
1241
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.
|
|
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.
|
|
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
|
|
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.
|