igniteui-angular 22.1.4 → 22.2.0-rc.1
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 +2 -2
- package/combo/README.md +19 -0
- package/drop-down/README.md +40 -4
- package/fesm2022/igniteui-angular-accordion.mjs +7 -7
- package/fesm2022/igniteui-angular-action-strip.mjs +10 -10
- 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 +12 -13
- package/fesm2022/igniteui-angular-button-group.mjs.map +1 -1
- package/fesm2022/igniteui-angular-calendar.mjs +61 -61
- package/fesm2022/igniteui-angular-card.mjs +31 -31
- 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 +268 -124
- package/fesm2022/igniteui-angular-combo.mjs.map +1 -1
- package/fesm2022/igniteui-angular-core.mjs +207 -59
- package/fesm2022/igniteui-angular-core.mjs.map +1 -1
- package/fesm2022/igniteui-angular-date-picker.mjs +80 -106
- package/fesm2022/igniteui-angular-date-picker.mjs.map +1 -1
- package/fesm2022/igniteui-angular-dialog.mjs +13 -13
- package/fesm2022/igniteui-angular-directives.mjs +321 -457
- package/fesm2022/igniteui-angular-directives.mjs.map +1 -1
- package/fesm2022/igniteui-angular-drop-down.mjs +331 -104
- package/fesm2022/igniteui-angular-drop-down.mjs.map +1 -1
- package/fesm2022/igniteui-angular-expansion-panel.mjs +28 -28
- package/fesm2022/igniteui-angular-grids-core.mjs +552 -516
- package/fesm2022/igniteui-angular-grids-core.mjs.map +1 -1
- package/fesm2022/igniteui-angular-grids-grid.mjs +79 -80
- package/fesm2022/igniteui-angular-grids-grid.mjs.map +1 -1
- package/fesm2022/igniteui-angular-grids-hierarchical-grid.mjs +59 -46
- package/fesm2022/igniteui-angular-grids-hierarchical-grid.mjs.map +1 -1
- package/fesm2022/igniteui-angular-grids-lite.mjs +12 -12
- package/fesm2022/igniteui-angular-grids-pivot-grid.mjs +113 -86
- package/fesm2022/igniteui-angular-grids-pivot-grid.mjs.map +1 -1
- package/fesm2022/igniteui-angular-grids-tree-grid.mjs +57 -57
- package/fesm2022/igniteui-angular-grids-tree-grid.mjs.map +1 -1
- package/fesm2022/igniteui-angular-icon.mjs +10 -10
- package/fesm2022/igniteui-angular-input-group.mjs +73 -83
- package/fesm2022/igniteui-angular-input-group.mjs.map +1 -1
- package/fesm2022/igniteui-angular-list.mjs +40 -40
- package/fesm2022/igniteui-angular-navbar.mjs +13 -13
- package/fesm2022/igniteui-angular-navigation-drawer.mjs +16 -16
- package/fesm2022/igniteui-angular-paginator.mjs +19 -19
- package/fesm2022/igniteui-angular-progressbar.mjs +19 -19
- package/fesm2022/igniteui-angular-query-builder.mjs +23 -23
- package/fesm2022/igniteui-angular-query-builder.mjs.map +1 -1
- package/fesm2022/igniteui-angular-radio.mjs +59 -35
- package/fesm2022/igniteui-angular-radio.mjs.map +1 -1
- package/fesm2022/igniteui-angular-select.mjs +36 -48
- package/fesm2022/igniteui-angular-select.mjs.map +1 -1
- package/fesm2022/igniteui-angular-simple-combo.mjs +44 -33
- 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 +32 -51
- package/fesm2022/igniteui-angular-time-picker.mjs.map +1 -1
- package/fesm2022/igniteui-angular-toast.mjs +7 -7
- package/fesm2022/igniteui-angular-tree.mjs +28 -28
- package/fesm2022/igniteui-angular-virtual-scroll.mjs +1257 -0
- package/fesm2022/igniteui-angular-virtual-scroll.mjs.map +1 -0
- package/fesm2022/igniteui-angular.mjs +1 -0
- package/fesm2022/igniteui-angular.mjs.map +1 -1
- package/input-group/README.md +2 -0
- package/lib/core/styles/components/scrollbar/scrollbar-component.scss +0 -2
- package/lib/core/styles/components/scrollbar/scrollbar-theme.scss +23 -51
- package/lib/grids/core/src/column-actions/themes/_derived.scss +3 -3
- package/lib/grids/core/src/filtering/excel-style/themes/_derived.scss +3 -3
- package/lib/grids/themes/_derived.scss +3 -3
- package/lib/query-builder/src/query-builder/themes/_derived.scss +3 -3
- package/migrations/common/UpdateChanges.d.ts +36 -0
- package/migrations/common/UpdateChanges.js +175 -31
- package/migrations/common/UpdateChanges.spec.js +66 -0
- package/migrations/migration-collection.json +10 -0
- package/migrations/update-22_2_0/changes/theme-changes.json +77 -0
- package/migrations/update-22_2_0/index.d.ts +3 -0
- package/migrations/update-22_2_0/index.js +18 -0
- package/migrations/update-22_2_0/index.spec.d.ts +1 -0
- package/migrations/update-22_2_0/index.spec.js +161 -0
- package/migrations/update-22_2_0_grid-summary-borders/changes/theme-changes.json +17 -0
- package/migrations/update-22_2_0_grid-summary-borders/index.d.ts +3 -0
- package/migrations/update-22_2_0_grid-summary-borders/index.js +64 -0
- package/migrations/update-22_2_0_grid-summary-borders/index.spec.d.ts +1 -0
- package/migrations/update-22_2_0_grid-summary-borders/index.spec.js +100 -0
- package/package.json +6 -2
- package/schematics/tsconfig.tsbuildinfo +1 -1
- package/simple-combo/README.md +3 -0
- package/skills/igniteui-angular-components/references/form-controls.md +58 -0
- package/styles/igniteui-angular-dark.css +1 -1
- package/styles/igniteui-angular.css +1 -1
- package/styles/igniteui-bootstrap-dark.css +1 -1
- package/styles/igniteui-bootstrap-light.css +1 -1
- package/styles/igniteui-dark-green.css +1 -1
- package/styles/igniteui-fluent-dark-excel.css +1 -1
- package/styles/igniteui-fluent-dark-word.css +1 -1
- package/styles/igniteui-fluent-dark.css +1 -1
- package/styles/igniteui-fluent-light-excel.css +1 -1
- package/styles/igniteui-fluent-light-word.css +1 -1
- package/styles/igniteui-fluent-light.css +1 -1
- package/styles/igniteui-indigo-dark.css +1 -1
- package/styles/igniteui-indigo-light.css +1 -1
- package/styles/maps/igniteui-angular-dark.css.map +1 -1
- package/styles/maps/igniteui-angular.css.map +1 -1
- package/styles/maps/igniteui-bootstrap-dark.css.map +1 -1
- package/styles/maps/igniteui-bootstrap-light.css.map +1 -1
- package/styles/maps/igniteui-dark-green.css.map +1 -1
- package/styles/maps/igniteui-fluent-dark-excel.css.map +1 -1
- package/styles/maps/igniteui-fluent-dark-word.css.map +1 -1
- package/styles/maps/igniteui-fluent-dark.css.map +1 -1
- package/styles/maps/igniteui-fluent-light-excel.css.map +1 -1
- package/styles/maps/igniteui-fluent-light-word.css.map +1 -1
- package/styles/maps/igniteui-fluent-light.css.map +1 -1
- package/styles/maps/igniteui-indigo-dark.css.map +1 -1
- package/styles/maps/igniteui-indigo-light.css.map +1 -1
- package/types/igniteui-angular-calendar.d.ts +1 -1
- package/types/igniteui-angular-combo.d.ts +55 -12
- package/types/igniteui-angular-core.d.ts +73 -11
- package/types/igniteui-angular-date-picker.d.ts +3 -4
- package/types/igniteui-angular-directives.d.ts +50 -164
- package/types/igniteui-angular-drop-down.d.ts +76 -4
- package/types/igniteui-angular-grids-core.d.ts +63 -28
- package/types/igniteui-angular-grids-grid.d.ts +2 -7
- package/types/igniteui-angular-grids-hierarchical-grid.d.ts +5 -4
- package/types/igniteui-angular-grids-pivot-grid.d.ts +11 -5
- package/types/igniteui-angular-grids-tree-grid.d.ts +1 -1
- package/types/igniteui-angular-icon.d.ts +1 -1
- package/types/igniteui-angular-input-group.d.ts +18 -5
- package/types/igniteui-angular-query-builder.d.ts +1 -1
- package/types/igniteui-angular-radio.d.ts +16 -1
- package/types/igniteui-angular-select.d.ts +1 -2
- package/types/igniteui-angular-simple-combo.d.ts +7 -0
- package/types/igniteui-angular-stepper.d.ts +1 -1
- package/types/igniteui-angular-time-picker.d.ts +1 -3
- package/types/igniteui-angular-tree.d.ts +3 -3
- package/types/igniteui-angular-virtual-scroll.d.ts +408 -0
- package/types/igniteui-angular.d.ts +1 -0
- package/virtual-scroll/README.md +332 -0
|
@@ -0,0 +1,1257 @@
|
|
|
1
|
+
import { isPlatformBrowser, NgTemplateOutlet } from '@angular/common';
|
|
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';
|
|
4
|
+
import { clamp, isLeftToRight } from 'igniteui-angular/core';
|
|
5
|
+
|
|
6
|
+
/** The maximum scroll coordinate is a per-document constant, so probe once. */
|
|
7
|
+
const _maxBrowserSizeCache = new WeakMap();
|
|
8
|
+
/** Measures the largest scroll coordinate the browser can represent. */
|
|
9
|
+
function probeMaxBrowserSize(doc) {
|
|
10
|
+
const cached = _maxBrowserSizeCache.get(doc);
|
|
11
|
+
if (cached !== undefined) {
|
|
12
|
+
return cached;
|
|
13
|
+
}
|
|
14
|
+
const container = doc.body ?? doc.documentElement;
|
|
15
|
+
if (!container) {
|
|
16
|
+
return Number.POSITIVE_INFINITY;
|
|
17
|
+
}
|
|
18
|
+
const probe = doc.createElement("div");
|
|
19
|
+
probe.style.position = "absolute";
|
|
20
|
+
probe.style.top = `${Number.MAX_SAFE_INTEGER}px`;
|
|
21
|
+
probe.style.width = "0";
|
|
22
|
+
probe.style.height = "0";
|
|
23
|
+
probe.style.visibility = "hidden";
|
|
24
|
+
container.appendChild(probe);
|
|
25
|
+
// The rect is viewport relative, so add back how far the document is scrolled.
|
|
26
|
+
const scrollOffset = doc.documentElement?.scrollTop ?? 0;
|
|
27
|
+
const size = Math.abs(probe.getBoundingClientRect().top) + scrollOffset;
|
|
28
|
+
container.removeChild(probe);
|
|
29
|
+
_maxBrowserSizeCache.set(doc, size);
|
|
30
|
+
return size;
|
|
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
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Binary Indexed Tree (Fenwick tree) over item sizes. Each hot-path operation
|
|
52
|
+
* is O(log N): point update (item measured), prefix sum (scroll offset), and
|
|
53
|
+
* index at offset (scroll to item, through binary lifting).
|
|
54
|
+
*/
|
|
55
|
+
class SizeTree {
|
|
56
|
+
constructor(sizes, tree, total, measured) {
|
|
57
|
+
this.length = sizes.length;
|
|
58
|
+
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);
|
|
73
|
+
}
|
|
74
|
+
/** Total size of all items. O(1). */
|
|
75
|
+
get totalSize() {
|
|
76
|
+
return this._total;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Prefix sum of items [0, i): the virtual scroll offset at the leading
|
|
80
|
+
* edge of item i. O(log N).
|
|
81
|
+
*/
|
|
82
|
+
prefixSum(i) {
|
|
83
|
+
let sum = 0;
|
|
84
|
+
for (let j = i; j > 0; j -= j & -j) {
|
|
85
|
+
sum += this._tree[j];
|
|
86
|
+
}
|
|
87
|
+
return sum;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
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).
|
|
93
|
+
*/
|
|
94
|
+
update(index, newSize) {
|
|
95
|
+
if (index < 0 || index >= this.length) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
const old = this._sizes[index];
|
|
99
|
+
this._measured[index] = 1;
|
|
100
|
+
if (old === newSize) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
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;
|
|
108
|
+
}
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
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.
|
|
117
|
+
*/
|
|
118
|
+
cloneResized(newLength, fillSize, retainCount = newLength) {
|
|
119
|
+
const sizes = new Float64Array(newLength).fill(fillSize);
|
|
120
|
+
const measured = new Uint8Array(newLength);
|
|
121
|
+
const retained = Math.max(0, Math.min(this.length, newLength, retainCount));
|
|
122
|
+
sizes.set(this._sizes.subarray(0, retained));
|
|
123
|
+
measured.set(this._measured.subarray(0, retained));
|
|
124
|
+
return SizeTree._build(sizes, measured);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
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.
|
|
132
|
+
*/
|
|
133
|
+
applyEstimate(estimatedSize) {
|
|
134
|
+
let changed = false;
|
|
135
|
+
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;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (!changed) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
this._tree.fill(0);
|
|
145
|
+
this._total = buildTree(this._tree, this._sizes);
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Returns the 0-based index of the item containing the scroll `offset`:
|
|
150
|
+
* the largest i where `prefixSum(i) <= offset < prefixSum(i + 1)`. O(log N).
|
|
151
|
+
*/
|
|
152
|
+
findIndexAtOffset(offset) {
|
|
153
|
+
if (offset <= 0 || this.length === 0) {
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
let index = 0;
|
|
157
|
+
let remaining = offset;
|
|
158
|
+
for (let bit = this._topBit; bit > 0; bit >>= 1) {
|
|
159
|
+
const next = index + bit;
|
|
160
|
+
if (next <= this.length && this._tree[next] <= remaining) {
|
|
161
|
+
index = next;
|
|
162
|
+
remaining -= this._tree[index];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return Math.min(this.length - 1, index);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Pure scroll-math engine for one axis of virtual scrolling. A Fenwick tree
|
|
170
|
+
* holds all size state, exposed through signals so downstream `computed()`
|
|
171
|
+
* values (visible range, spacer size, translate offset) react to any change
|
|
172
|
+
* of item sizes or item count.
|
|
173
|
+
*
|
|
174
|
+
* ### Virtual and DOM coordinates
|
|
175
|
+
*
|
|
176
|
+
* Browsers limit how far an element can scroll. When the total item size is
|
|
177
|
+
* larger than that limit, the engine maps the *virtual* scroll range
|
|
178
|
+
* (`0…totalSize - viewport`) onto the *DOM* scroll range the browser can
|
|
179
|
+
* represent (`0…domSize - viewport`), see `_scrollRanges`. Incoming scroll
|
|
180
|
+
* positions are converted to virtual offsets, outgoing offsets to DOM ones.
|
|
181
|
+
* Items render at their real pixel size, so item sizes are always virtual.
|
|
182
|
+
*/
|
|
183
|
+
class VirtualScrollEngine {
|
|
184
|
+
constructor() {
|
|
185
|
+
this._maxBrowserSize = Number.POSITIVE_INFINITY;
|
|
186
|
+
this._tree = null;
|
|
187
|
+
/** Bumped on every structural change: resize, measurement or estimate. */
|
|
188
|
+
this._version = signal(0, /* @ts-ignore */
|
|
189
|
+
...(ngDevMode ? [{ debugName: "_version" }] : /* istanbul ignore next */ []));
|
|
190
|
+
/**
|
|
191
|
+
* Read this from a `computed()` to make it recompute on any size change.
|
|
192
|
+
* `totalSize` and `domSize` already do.
|
|
193
|
+
*/
|
|
194
|
+
this.version = this._version.asReadonly();
|
|
195
|
+
/** Total virtual size of all items in px. */
|
|
196
|
+
this.totalSize = computed(() => {
|
|
197
|
+
this._version();
|
|
198
|
+
return this._tree?.totalSize ?? 0;
|
|
199
|
+
}, /* @ts-ignore */
|
|
200
|
+
...(ngDevMode ? [{ debugName: "totalSize" }] : /* istanbul ignore next */ []));
|
|
201
|
+
/**
|
|
202
|
+
* Total size in DOM space, clamped to the maximum browser size.
|
|
203
|
+
*
|
|
204
|
+
* Depends on `_version` directly rather than on `totalSize()`. The maximum
|
|
205
|
+
* can change while the total does not (it is probed after the first
|
|
206
|
+
* render), and an unchanged `totalSize` would not propagate.
|
|
207
|
+
*/
|
|
208
|
+
this.domSize = computed(() => {
|
|
209
|
+
this._version();
|
|
210
|
+
return Math.min(this._tree?.totalSize ?? 0, this._maxBrowserSize);
|
|
211
|
+
}, /* @ts-ignore */
|
|
212
|
+
...(ngDevMode ? [{ debugName: "domSize" }] : /* istanbul ignore next */ []));
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Probes the document's maximum scroll coordinate and rescales. Notifies,
|
|
216
|
+
* because the probe can only run after the first render, by which point
|
|
217
|
+
* `domSize` has already been read at the uncompressed total.
|
|
218
|
+
*/
|
|
219
|
+
initMaxBrowserSize(doc) {
|
|
220
|
+
this._maxBrowserSize = probeMaxBrowserSize(doc);
|
|
221
|
+
this._invalidate();
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* 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.
|
|
229
|
+
*/
|
|
230
|
+
resize(length, estimatedSize, retainCount = length) {
|
|
231
|
+
if (this._tree?.length === length && retainCount >= length) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
this._tree = this._tree
|
|
235
|
+
? this._tree.cloneResized(length, estimatedSize, retainCount)
|
|
236
|
+
: SizeTree.filled(length, estimatedSize);
|
|
237
|
+
this._invalidate();
|
|
238
|
+
}
|
|
239
|
+
/** Records the measured DOM size for a single item. */
|
|
240
|
+
measureItem(index, size) {
|
|
241
|
+
if (this._tree?.update(index, size)) {
|
|
242
|
+
this._invalidate();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Applies a new estimate to every item not yet measured in the DOM. Use
|
|
247
|
+
* this when `estimatedItemSize` changes but the item count does not,
|
|
248
|
+
* because `resize` is then a no-op.
|
|
249
|
+
*/
|
|
250
|
+
updateEstimatedSize(estimatedSize) {
|
|
251
|
+
if (this._tree?.applyEstimate(estimatedSize)) {
|
|
252
|
+
this._invalidate();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Returns the DOM scroll offset in px that puts the item at `index` at the
|
|
257
|
+
* leading edge of a `viewportSize` px viewport.
|
|
258
|
+
*/
|
|
259
|
+
getScrollOffsetForIndex(index, viewportSize) {
|
|
260
|
+
if (!this._tree || index <= 0) {
|
|
261
|
+
return 0;
|
|
262
|
+
}
|
|
263
|
+
return this._toDom(this._tree.prefixSum(Math.min(index, this._tree.length)), viewportSize);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Returns the DOM scroll offset that positions the item at `index` in a
|
|
267
|
+
* `viewportSize` px viewport, aligned by `align` and clamped to the
|
|
268
|
+
* reachable scroll range.
|
|
269
|
+
*
|
|
270
|
+
* The slack is computed in virtual space against the item's real size and
|
|
271
|
+
* converted to DOM space once, at the end. Under compression one DOM pixel
|
|
272
|
+
* is several virtual pixels, so mixed coordinates would scale the slack.
|
|
273
|
+
*/
|
|
274
|
+
getAlignedScrollOffset(index, viewportSize, align) {
|
|
275
|
+
const bounds = this._itemBounds(index);
|
|
276
|
+
if (!bounds) {
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
|
279
|
+
const [start, end] = bounds;
|
|
280
|
+
const slack = viewportSize - Math.max(0, end - start);
|
|
281
|
+
let offset = start;
|
|
282
|
+
if (align === "center") {
|
|
283
|
+
offset -= slack / 2;
|
|
284
|
+
}
|
|
285
|
+
else if (align === "end") {
|
|
286
|
+
offset -= slack;
|
|
287
|
+
}
|
|
288
|
+
return clamp(this._toDom(offset, viewportSize), 0, Math.max(0, this.domSize() - viewportSize));
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
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.
|
|
296
|
+
*/
|
|
297
|
+
getNearestAlignment(index, scrollPosition, viewportSize) {
|
|
298
|
+
const bounds = this._itemBounds(index);
|
|
299
|
+
if (!bounds) {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
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";
|
|
311
|
+
}
|
|
312
|
+
/** Returns the visible and over-scanned item range for the given scroll state. */
|
|
313
|
+
getVisibleRange(scrollPosition, viewportSize, overScan) {
|
|
314
|
+
if (!this._tree || this._tree.length === 0 || viewportSize <= 0) {
|
|
315
|
+
return { startIndex: 0, endIndex: -1 };
|
|
316
|
+
}
|
|
317
|
+
// The viewport is not compressed. Items render at their real pixel size,
|
|
318
|
+
// so a `viewportSize` px viewport always shows that many virtual pixels of
|
|
319
|
+
// items, at any compression of the scroll range.
|
|
320
|
+
const startOffset = this._toVirtual(scrollPosition, viewportSize);
|
|
321
|
+
const first = this._tree.findIndexAtOffset(startOffset);
|
|
322
|
+
const last = this._tree.findIndexAtOffset(startOffset + viewportSize);
|
|
323
|
+
return {
|
|
324
|
+
startIndex: Math.max(0, first - overScan),
|
|
325
|
+
endIndex: Math.min(this._tree.length - 1, last + overScan),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Sum of the real sizes of the items in [startIndex, endIndex]. The render
|
|
330
|
+
* pass uses it to clamp the content translate offset, so rendered items do
|
|
331
|
+
* not overflow past `domSize` under coordinate compression.
|
|
332
|
+
*/
|
|
333
|
+
getPhysicalRangeSize(startIndex, endIndex) {
|
|
334
|
+
if (!this._tree) {
|
|
335
|
+
return 0;
|
|
336
|
+
}
|
|
337
|
+
const start = Math.max(0, startIndex);
|
|
338
|
+
const end = Math.min(Math.max(endIndex + 1, start), this._tree.length);
|
|
339
|
+
return this._tree.prefixSum(end) - this._tree.prefixSum(start);
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* The virtual [start, end] offsets of the item at `index`, clamped into the
|
|
343
|
+
* item range. Null while there are no items.
|
|
344
|
+
*/
|
|
345
|
+
_itemBounds(index) {
|
|
346
|
+
if (!this._tree || this._tree.length === 0) {
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
const clamped = clamp(index, 0, this._tree.length - 1);
|
|
350
|
+
return [this._tree.prefixSum(clamped), this._tree.prefixSum(clamped + 1)];
|
|
351
|
+
}
|
|
352
|
+
_invalidate() {
|
|
353
|
+
this._version.update((v) => v + 1);
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* A virtual offset as the DOM scroll position that shows it, for a
|
|
357
|
+
* `viewportSize` px viewport.
|
|
358
|
+
*/
|
|
359
|
+
_toDom(virtualOffset, viewportSize) {
|
|
360
|
+
const [virtualRange, domRange] = this._scrollRanges(viewportSize);
|
|
361
|
+
return (virtualOffset * domRange) / virtualRange;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* A DOM scroll position as the virtual offset it shows, for a
|
|
365
|
+
* `viewportSize` px viewport.
|
|
366
|
+
*/
|
|
367
|
+
_toVirtual(domOffset, viewportSize) {
|
|
368
|
+
const [virtualRange, domRange] = this._scrollRanges(viewportSize);
|
|
369
|
+
return (Math.max(0, domOffset) * virtualRange) / domRange;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* The virtual and DOM scroll ranges to map between; the identity while the
|
|
373
|
+
* content fits the browser's limit. One viewport of items renders at real
|
|
374
|
+
* size at any scroll position, so both ranges exclude it: mapping the totals
|
|
375
|
+
* instead would leave the last `viewport * (ratio - 1)` virtual pixels past
|
|
376
|
+
* the largest DOM offset. Callers multiply before dividing, so exact
|
|
377
|
+
* positions come back exact.
|
|
378
|
+
*/
|
|
379
|
+
_scrollRanges(viewportSize) {
|
|
380
|
+
const totalSize = this._tree?.totalSize ?? 0;
|
|
381
|
+
const domRange = this._maxBrowserSize - viewportSize;
|
|
382
|
+
if (totalSize <= this._maxBrowserSize || domRange <= 0) {
|
|
383
|
+
return [1, 1];
|
|
384
|
+
}
|
|
385
|
+
return [totalSize - viewportSize, domRange];
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Template context for a single item of the virtual scroll. */
|
|
390
|
+
class IgxVsItemContext {
|
|
391
|
+
constructor(
|
|
392
|
+
/** The current item in the virtual scroll. */
|
|
393
|
+
$implicit,
|
|
394
|
+
/** The index of the current item. */
|
|
395
|
+
index,
|
|
396
|
+
/** The total number of items in the virtual scroll. */
|
|
397
|
+
count) {
|
|
398
|
+
this.$implicit = $implicit;
|
|
399
|
+
this.index = index;
|
|
400
|
+
this.count = count;
|
|
401
|
+
}
|
|
402
|
+
/** Whether the current item is the first in the list. */
|
|
403
|
+
get first() {
|
|
404
|
+
return this.index === 0;
|
|
405
|
+
}
|
|
406
|
+
/** Whether the current item is the last in the list. */
|
|
407
|
+
get last() {
|
|
408
|
+
return this.index === this.count - 1;
|
|
409
|
+
}
|
|
410
|
+
/** Whether the current item is at an even index. */
|
|
411
|
+
get even() {
|
|
412
|
+
return this.index % 2 === 0;
|
|
413
|
+
}
|
|
414
|
+
/** Whether the current item is at an odd index. */
|
|
415
|
+
get odd() {
|
|
416
|
+
return !this.even;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Directive to mark an `ng-template` as the item template for the virtual scroll component.
|
|
422
|
+
* The template provided by this directive will be used to render each item in the virtual scroll.
|
|
423
|
+
* The context for the template will include the item data and its index.
|
|
424
|
+
*
|
|
425
|
+
* @example
|
|
426
|
+
* ```html
|
|
427
|
+
* <igx-virtual-scroll [data]="items">
|
|
428
|
+
* <ng-template igxVirtualItem let-item let-i="index">
|
|
429
|
+
* <div>{{ i }}: {{ item }}</div>
|
|
430
|
+
* </ng-template>
|
|
431
|
+
* </igx-virtual-scroll>
|
|
432
|
+
* ```
|
|
433
|
+
*/
|
|
434
|
+
class IgxVirtualItemDirective {
|
|
435
|
+
constructor() {
|
|
436
|
+
this.template = inject(TemplateRef);
|
|
437
|
+
}
|
|
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 }); }
|
|
440
|
+
}
|
|
441
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: IgxVirtualItemDirective, decorators: [{
|
|
442
|
+
type: Directive,
|
|
443
|
+
args: [{ selector: "ng-template[igxVirtualItem]" }]
|
|
444
|
+
}] });
|
|
445
|
+
|
|
446
|
+
/** Defaults for the inputs, also used as the fallback for invalid values. */
|
|
447
|
+
const DEFAULT_OVER_SCAN = 2;
|
|
448
|
+
const DEFAULT_ESTIMATED_ITEM_SIZE = 50;
|
|
449
|
+
/** How close to the end of `data` the window must get to emit `dataRequest`. */
|
|
450
|
+
const DATA_REQUEST_THRESHOLD = 5;
|
|
451
|
+
const DATA_REQUEST_MIN_COUNT = 20;
|
|
452
|
+
const DATA_REQUEST_OVER_SCAN_FACTOR = 4;
|
|
453
|
+
/** Give-up bounds for the two loops that wait for the layout to stabilize. */
|
|
454
|
+
const MAX_LAYOUT_SETTLE_PASSES = 20;
|
|
455
|
+
const MAX_SCROLL_CORRECTION_PASSES = 5;
|
|
456
|
+
const SCROLL_END_TIMEOUT_MS = 2000;
|
|
457
|
+
const SCROLL_OFFSET_EPSILON_PX = 1;
|
|
458
|
+
/** How long the scroll position must stay unchanged to count as settled. */
|
|
459
|
+
const SCROLL_IDLE_MS = 100;
|
|
460
|
+
/**
|
|
461
|
+
* Upper limit on one `requestAnimationFrame` wait. A hidden tab or a detached
|
|
462
|
+
* element gets no frames, and `layoutComplete` must still resolve there.
|
|
463
|
+
*/
|
|
464
|
+
const LAYOUT_FRAME_TIMEOUT_MS = 100;
|
|
465
|
+
const EMPTY_RANGE = Object.freeze({ startIndex: 0, endIndex: -1 });
|
|
466
|
+
/** A consumer-supplied index, count or size, reduced to a whole non-negative number. */
|
|
467
|
+
function toCount(value) {
|
|
468
|
+
const count = Math.trunc(value);
|
|
469
|
+
return Number.isFinite(count) ? Math.max(0, count) : 0;
|
|
470
|
+
}
|
|
471
|
+
function loadedEqual(a, b) {
|
|
472
|
+
return (a.items === b.items &&
|
|
473
|
+
a.startIndex === b.startIndex &&
|
|
474
|
+
a.totalCount === b.totalCount &&
|
|
475
|
+
a.windowed === b.windowed);
|
|
476
|
+
}
|
|
477
|
+
function rangesEqual(a, b) {
|
|
478
|
+
return a.startIndex === b.startIndex && a.endIndex === b.endIndex;
|
|
479
|
+
}
|
|
480
|
+
function statesEqual(a, b) {
|
|
481
|
+
return (a !== null &&
|
|
482
|
+
rangesEqual(a, b) &&
|
|
483
|
+
a.viewportSize === b.viewportSize &&
|
|
484
|
+
a.totalSize === b.totalSize);
|
|
485
|
+
}
|
|
486
|
+
/** The data index an item wrapper carries, or -1 when it has none. */
|
|
487
|
+
function itemIndex(element) {
|
|
488
|
+
const index = Number.parseInt(element.dataset["index"] ?? "", 10);
|
|
489
|
+
return Number.isInteger(index) && index >= 0 ? index : -1;
|
|
490
|
+
}
|
|
491
|
+
function onAbort(abort, cancel) {
|
|
492
|
+
abort.addEventListener("abort", cancel, { once: true });
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* A virtual scroll component for large lists. Only the items visible in the
|
|
496
|
+
* viewport (plus a configurable over-scan) are rendered.
|
|
497
|
+
*
|
|
498
|
+
* @igxKeywords virtual, scroll, virtualization, list
|
|
499
|
+
* @igxGroup Grids & Lists
|
|
500
|
+
*
|
|
501
|
+
* @example
|
|
502
|
+
* ```html
|
|
503
|
+
* <igx-virtual-scroll [data]="items" style="height: 400px">
|
|
504
|
+
* <ng-template igxVirtualItem let-item let-i="index">
|
|
505
|
+
* <div>{{ i }}: {{ item }}</div>
|
|
506
|
+
* </ng-template>
|
|
507
|
+
* </igx-virtual-scroll>
|
|
508
|
+
* ```
|
|
509
|
+
*/
|
|
510
|
+
class IgxVirtualScrollComponent {
|
|
511
|
+
//#endregion
|
|
512
|
+
constructor() {
|
|
513
|
+
//#region Dependency injection
|
|
514
|
+
this._hostRef = inject(ElementRef);
|
|
515
|
+
this._zone = inject(NgZone);
|
|
516
|
+
this._document = inject(DOCUMENT);
|
|
517
|
+
this._isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
518
|
+
//#endregion
|
|
519
|
+
//#region Internal state
|
|
520
|
+
this._engine = new VirtualScrollEngine();
|
|
521
|
+
this._viewportResizeObserver = null;
|
|
522
|
+
this._itemResizeObserver = null;
|
|
523
|
+
this._onScroll = null;
|
|
524
|
+
/** Elements currently registered with the item resize observer. */
|
|
525
|
+
this._observedItems = new Set();
|
|
526
|
+
/** The data index each observed wrapper element last hosted. */
|
|
527
|
+
this._observedItemIndexes = new WeakMap();
|
|
528
|
+
/**
|
|
529
|
+
* The live scroll offset on the active axis. A plain field, not a signal:
|
|
530
|
+
* `_visibleRange` reads it but is invalidated by `_scrollTick`, so a scroll
|
|
531
|
+
* that does not move the rendered window schedules no work.
|
|
532
|
+
*/
|
|
533
|
+
this._scrollPosition = 0;
|
|
534
|
+
/** Bumped only when a scroll actually moves the rendered window. */
|
|
535
|
+
this._scrollTick = signal(0, /* @ts-ignore */
|
|
536
|
+
...(ngDevMode ? [{ debugName: "_scrollTick" }] : /* istanbul ignore next */ []));
|
|
537
|
+
/** The measured viewport, or `null` while the host has never been laid out. */
|
|
538
|
+
this._viewportSize = signal(null, /* @ts-ignore */
|
|
539
|
+
...(ngDevMode ? [{ debugName: "_viewportSize" }] : /* istanbul ignore next */ []));
|
|
540
|
+
/**
|
|
541
|
+
* Whether the host is laid out left-to-right, as of the last read of the
|
|
542
|
+
* horizontal axis. `_contentTransform` depends on it, so a `dir` change
|
|
543
|
+
* flips the wrapper even while the rendered window stays put.
|
|
544
|
+
*/
|
|
545
|
+
this._ltr = signal(true, /* @ts-ignore */
|
|
546
|
+
...(ngDevMode ? [{ debugName: "_ltr" }] : /* istanbul ignore next */ []));
|
|
547
|
+
this._lastEmittedState = null;
|
|
548
|
+
this._hasPendingDataRequest = false;
|
|
549
|
+
/**
|
|
550
|
+
* The `startIndex` of the last emitted `dataRequest`, which is also the
|
|
551
|
+
* item count at that emit. See `_checkDataRequest`.
|
|
552
|
+
*/
|
|
553
|
+
this._lastDataRequestIndex = -1;
|
|
554
|
+
this._layoutCompletePromise = null;
|
|
555
|
+
this._scrollRequestId = 0;
|
|
556
|
+
//#endregion
|
|
557
|
+
//#region View and content children
|
|
558
|
+
this._itemDirective = contentChild(IgxVirtualItemDirective, /* @ts-ignore */
|
|
559
|
+
...(ngDevMode ? [{ debugName: "_itemDirective" }] : /* istanbul ignore next */ []));
|
|
560
|
+
this._contentDivRef = viewChild("contentDiv", /* @ts-ignore */
|
|
561
|
+
...(ngDevMode ? [{ debugName: "_contentDivRef" }] : /* istanbul ignore next */ []));
|
|
562
|
+
//#endregion
|
|
563
|
+
//#region Public inputs
|
|
564
|
+
/**
|
|
565
|
+
* The array of items to virtualize.
|
|
566
|
+
*
|
|
567
|
+
* Compared by reference: mutating the array in place (`data.push(...)`)
|
|
568
|
+
* causes no update. Assign a new array instead. The `dataRequest` flow
|
|
569
|
+
* also expects a new array.
|
|
570
|
+
*/
|
|
571
|
+
this.data = input([], /* @ts-ignore */
|
|
572
|
+
...(ngDevMode ? [{ debugName: "data" }] : /* istanbul ignore next */ []));
|
|
573
|
+
/**
|
|
574
|
+
* Scroll orientation of the virtual scroll.
|
|
575
|
+
* Can be either "vertical" or "horizontal".
|
|
576
|
+
* Default is "vertical".
|
|
577
|
+
*/
|
|
578
|
+
this.orientation = input("vertical", /* @ts-ignore */
|
|
579
|
+
...(ngDevMode ? [{ debugName: "orientation" }] : /* istanbul ignore next */ []));
|
|
580
|
+
/**
|
|
581
|
+
* Number of extra items to render beyond the visible area of the viewport.
|
|
582
|
+
* Higher values reduce blank flashes during fast scrolling but may impact performance.
|
|
583
|
+
* Default is 2.
|
|
584
|
+
*/
|
|
585
|
+
this.overScan = input(DEFAULT_OVER_SCAN, /* @ts-ignore */
|
|
586
|
+
...(ngDevMode ? [{ debugName: "overScan" }] : /* istanbul ignore next */ []));
|
|
587
|
+
/**
|
|
588
|
+
* Estimated item size in pixels used before an item is measured in the DOM.
|
|
589
|
+
* The engine replaces this with the actual measured size after the first render of each item.
|
|
590
|
+
* Default is 50 pixels.
|
|
591
|
+
* Setting this to a value close to the actual average item size can improve initial rendering performance.
|
|
592
|
+
*/
|
|
593
|
+
this.estimatedItemSize = input(DEFAULT_ESTIMATED_ITEM_SIZE, /* @ts-ignore */
|
|
594
|
+
...(ngDevMode ? [{ debugName: "estimatedItemSize" }] : /* istanbul ignore next */ []));
|
|
595
|
+
/**
|
|
596
|
+
* A loaded page of a larger collection, for data that arrives a page at a time.
|
|
597
|
+
*
|
|
598
|
+
* Takes the place of `data` while it is set. The list is as long as `totalCount`, so the
|
|
599
|
+
* scrollbar spans the whole collection while only the page is in memory. Indices the page
|
|
600
|
+
* does not cover render nothing; use `stateChange` to see which range is wanted and supply
|
|
601
|
+
* the page that covers it.
|
|
602
|
+
*
|
|
603
|
+
* Measured sizes are kept per index across pages while `totalCount` holds: the indices still
|
|
604
|
+
* mean the same records. A page with another `totalCount` is a different collection, and
|
|
605
|
+
* every size is measured again. A page that keeps the count but puts different records at
|
|
606
|
+
* the same indices keeps the sizes measured for the previous ones until those rows render.
|
|
607
|
+
*
|
|
608
|
+
* @example
|
|
609
|
+
* ```html
|
|
610
|
+
* <igx-virtual-scroll [dataWindow]="page" (stateChange)="load($event)" style="height: 400px">
|
|
611
|
+
* <ng-template igxVirtualItem let-item>{{ item?.name }}</ng-template>
|
|
612
|
+
* </igx-virtual-scroll>
|
|
613
|
+
* ```
|
|
614
|
+
*/
|
|
615
|
+
this.dataWindow = input(null, /* @ts-ignore */
|
|
616
|
+
...(ngDevMode ? [{ debugName: "dataWindow" }] : /* istanbul ignore next */ []));
|
|
617
|
+
/**
|
|
618
|
+
* @hidden @internal
|
|
619
|
+
*
|
|
620
|
+
* Viewport size in pixels to render the first window against, for a list that is hidden
|
|
621
|
+
* until the change detection pass that reveals it and so has no size to measure in it.
|
|
622
|
+
*
|
|
623
|
+
* A hint for that first render: once the host has been laid out its own size takes over,
|
|
624
|
+
* zero included. Changing `orientation` begins an axis with no measurement of its own, so
|
|
625
|
+
* the hint applies again there. Negative, `NaN` and infinite values count as no hint.
|
|
626
|
+
*
|
|
627
|
+
* A workaround for the drop-down family, which reads and focuses items in the pass that
|
|
628
|
+
* opens the list. To be removed once those components render their list on reveal.
|
|
629
|
+
*/
|
|
630
|
+
this.initialViewportSize = input(0, /* @ts-ignore */
|
|
631
|
+
...(ngDevMode ? [{ debugName: "initialViewportSize" }] : /* istanbul ignore next */ []));
|
|
632
|
+
/**
|
|
633
|
+
* Item template provided programmatically. Takes precedence over a content
|
|
634
|
+
* `ng-template[igxVirtualItem]` when both are provided.
|
|
635
|
+
*
|
|
636
|
+
* Items are measured by their border box, so margins accumulate as drift
|
|
637
|
+
* down the list. Use padding on the item, or a gap on a wrapper, instead.
|
|
638
|
+
*
|
|
639
|
+
* Only the current window is in the DOM, so assistive technology cannot
|
|
640
|
+
* infer an item's position from the markup. Templates that render a role
|
|
641
|
+
* with set semantics (`listitem`, `option`, `row`, ...) should map the
|
|
642
|
+
* context's `index` and `count` onto `aria-posinset` and `aria-setsize`.
|
|
643
|
+
*/
|
|
644
|
+
this.itemTemplate = input(null, /* @ts-ignore */
|
|
645
|
+
...(ngDevMode ? [{ debugName: "itemTemplate" }] : /* istanbul ignore next */ []));
|
|
646
|
+
//#endregion
|
|
647
|
+
//#region Public outputs
|
|
648
|
+
/** Emitted when the rendered virtual window changes. */
|
|
649
|
+
this.stateChange = output();
|
|
650
|
+
/**
|
|
651
|
+
* Emitted when the rendered window comes within a few items of the end of
|
|
652
|
+
* `data`. Also emitted on the first render, when the loaded items do not
|
|
653
|
+
* fill the viewport. Listen to this event to append more items
|
|
654
|
+
* (infinite / remote scrolling).
|
|
655
|
+
*/
|
|
656
|
+
this.dataRequest = output();
|
|
657
|
+
//#endregion
|
|
658
|
+
//#region Derived state
|
|
659
|
+
this._isVertical = computed(() => this.orientation() === "vertical", /* @ts-ignore */
|
|
660
|
+
...(ngDevMode ? [{ debugName: "_isVertical" }] : /* istanbul ignore next */ []));
|
|
661
|
+
this._resolvedTemplate = computed(() => this.itemTemplate() ?? this._itemDirective()?.template ?? null, /* @ts-ignore */
|
|
662
|
+
...(ngDevMode ? [{ debugName: "_resolvedTemplate" }] : /* istanbul ignore next */ []));
|
|
663
|
+
/**
|
|
664
|
+
* What the component has to work with, from whichever data input is in use. A page is
|
|
665
|
+
* trusted to be no longer than the collection it says it belongs to.
|
|
666
|
+
*/
|
|
667
|
+
this._loaded = computed(() => {
|
|
668
|
+
const window = this.dataWindow();
|
|
669
|
+
if (!window) {
|
|
670
|
+
const items = this.data() ?? [];
|
|
671
|
+
return { items, startIndex: 0, totalCount: items.length, windowed: false };
|
|
672
|
+
}
|
|
673
|
+
const items = window.items ?? [];
|
|
674
|
+
const startIndex = toCount(window.startIndex);
|
|
675
|
+
return {
|
|
676
|
+
items,
|
|
677
|
+
startIndex,
|
|
678
|
+
totalCount: Math.max(toCount(window.totalCount), startIndex + items.length),
|
|
679
|
+
windowed: true,
|
|
680
|
+
};
|
|
681
|
+
}, { ...(ngDevMode ? { debugName: "_loaded" } : /* istanbul ignore next */ {}), equal: loadedEqual });
|
|
682
|
+
/** The measured size once the host has been laid out, the hint until then. */
|
|
683
|
+
this._effectiveViewportSize = computed(() => this._viewportSize() ?? toCount(this.initialViewportSize()), /* @ts-ignore */
|
|
684
|
+
...(ngDevMode ? [{ debugName: "_effectiveViewportSize" }] : /* istanbul ignore next */ []));
|
|
685
|
+
/** The configured `overScan`, normalized to a non-negative integer. */
|
|
686
|
+
this._normalizedOverScan = computed(() => {
|
|
687
|
+
const value = Number(this.overScan());
|
|
688
|
+
return Number.isFinite(value)
|
|
689
|
+
? Math.max(0, Math.floor(value))
|
|
690
|
+
: DEFAULT_OVER_SCAN;
|
|
691
|
+
}, /* @ts-ignore */
|
|
692
|
+
...(ngDevMode ? [{ debugName: "_normalizedOverScan" }] : /* istanbul ignore next */ []));
|
|
693
|
+
/** The configured `estimatedItemSize`, normalized to a positive number. */
|
|
694
|
+
this._normalizedItemSize = computed(() => {
|
|
695
|
+
const value = Number(this.estimatedItemSize());
|
|
696
|
+
return Number.isFinite(value) && value > 0
|
|
697
|
+
? value
|
|
698
|
+
: DEFAULT_ESTIMATED_ITEM_SIZE;
|
|
699
|
+
}, /* @ts-ignore */
|
|
700
|
+
...(ngDevMode ? [{ debugName: "_normalizedItemSize" }] : /* istanbul ignore next */ []));
|
|
701
|
+
/**
|
|
702
|
+
* The window to render for the current scroll position and viewport. Empty
|
|
703
|
+
* until an item template is resolved, because nothing renders without one.
|
|
704
|
+
*
|
|
705
|
+
* The scroll position is read from the plain `_scrollPosition` field so
|
|
706
|
+
* that a recompute triggered by a measurement uses the live offset, while
|
|
707
|
+
* `_scrollTick` limits recomputes to scrolls that actually move the window.
|
|
708
|
+
*/
|
|
709
|
+
this._visibleRange = computed(() => {
|
|
710
|
+
// Depend on the engine so the range recomputes whenever item sizes or
|
|
711
|
+
// the item count change.
|
|
712
|
+
this._engine.version();
|
|
713
|
+
this._scrollTick();
|
|
714
|
+
return this._resolvedTemplate()
|
|
715
|
+
? this._engine.getVisibleRange(this._scrollPosition, this._effectiveViewportSize(), this._normalizedOverScan())
|
|
716
|
+
: EMPTY_RANGE;
|
|
717
|
+
}, { ...(ngDevMode ? { debugName: "_visibleRange" } : /* istanbul ignore next */ {}), equal: rangesEqual });
|
|
718
|
+
/** The track size, in DOM space. */
|
|
719
|
+
this._spaceSize = this._engine.domSize;
|
|
720
|
+
/** The part of the rendered range a page actually covers. */
|
|
721
|
+
this._loadedRange = computed(() => {
|
|
722
|
+
const { startIndex, endIndex } = this._visibleRange();
|
|
723
|
+
const { items, startIndex: from } = this._loaded();
|
|
724
|
+
return {
|
|
725
|
+
startIndex: Math.max(startIndex, from),
|
|
726
|
+
endIndex: Math.min(endIndex, from + items.length - 1),
|
|
727
|
+
};
|
|
728
|
+
}, { ...(ngDevMode ? { debugName: "_loadedRange" } : /* istanbul ignore next */ {}), equal: rangesEqual });
|
|
729
|
+
/** The item contexts for the currently rendered window, in render order. */
|
|
730
|
+
this._renderedItems = computed(() => {
|
|
731
|
+
const { startIndex, endIndex } = this._loadedRange();
|
|
732
|
+
const { items, startIndex: from, totalCount } = this._loaded();
|
|
733
|
+
const rendered = [];
|
|
734
|
+
for (let i = startIndex; i <= endIndex; i++) {
|
|
735
|
+
rendered.push(new IgxVsItemContext(items[i - from], i, totalCount));
|
|
736
|
+
}
|
|
737
|
+
return rendered;
|
|
738
|
+
}, /* @ts-ignore */
|
|
739
|
+
...(ngDevMode ? [{ debugName: "_renderedItems" }] : /* istanbul ignore next */ []));
|
|
740
|
+
/**
|
|
741
|
+
* The `translateY` / `translateX` for the content wrapper. It is absolutely
|
|
742
|
+
* positioned at the origin of a `domSize` px track, so translating it to
|
|
743
|
+
* the first rendered item's offset puts that item at its virtual position.
|
|
744
|
+
*/
|
|
745
|
+
this._contentTransform = computed(() => {
|
|
746
|
+
// The offsets below are plain reads of the engine's size state, so depend
|
|
747
|
+
// on its version explicitly.
|
|
748
|
+
this._engine.version();
|
|
749
|
+
const range = this._loadedRange();
|
|
750
|
+
// Under coordinate compression item positions are scaled down but item
|
|
751
|
+
// sizes are not. Without this cap the rendered range would overflow past
|
|
752
|
+
// domSize at the end of the list, pushing the last items beyond the
|
|
753
|
+
// maximum browser scroll coordinate.
|
|
754
|
+
const position = clamp(this._engine.getScrollOffsetForIndex(range.startIndex, this._effectiveViewportSize()), 0, this._engine.domSize() -
|
|
755
|
+
this._engine.getPhysicalRangeSize(range.startIndex, range.endIndex));
|
|
756
|
+
if (this._isVertical()) {
|
|
757
|
+
return `translateY(${position}px)`;
|
|
758
|
+
}
|
|
759
|
+
// In RTL the wrapper is anchored to the right edge of the track, so it
|
|
760
|
+
// translates towards the negative (leading) direction.
|
|
761
|
+
return `translateX(${this._ltr() ? position : -position}px)`;
|
|
762
|
+
}, /* @ts-ignore */
|
|
763
|
+
...(ngDevMode ? [{ debugName: "_contentTransform" }] : /* istanbul ignore next */ []));
|
|
764
|
+
// Sync the engine's item count with `data`, discarding the measurements
|
|
765
|
+
// of items whose identity changed.
|
|
766
|
+
effect(() => {
|
|
767
|
+
const loaded = this._loaded();
|
|
768
|
+
untracked(() => {
|
|
769
|
+
const previous = this._previousItems;
|
|
770
|
+
const retained = this._retainCount(previous, loaded);
|
|
771
|
+
this._previousItems = loaded;
|
|
772
|
+
this._engine.resize(loaded.totalCount, this._normalizedItemSize(), retained);
|
|
773
|
+
this._remeasureFrom(retained);
|
|
774
|
+
// New data (or a reset) clears any in-flight data request so the next
|
|
775
|
+
// approach to the end of the list can emit again.
|
|
776
|
+
this._hasPendingDataRequest = false;
|
|
777
|
+
});
|
|
778
|
+
});
|
|
779
|
+
// Re-apply the estimate when it changes but the item count does not,
|
|
780
|
+
// because `resize` is then a no-op.
|
|
781
|
+
effect(() => {
|
|
782
|
+
const size = this._normalizedItemSize();
|
|
783
|
+
untracked(() => this._engine.updateEstimatedSize(size));
|
|
784
|
+
});
|
|
785
|
+
// The scroll offset of the previous axis does not carry over.
|
|
786
|
+
effect(() => {
|
|
787
|
+
this.orientation();
|
|
788
|
+
untracked(() => {
|
|
789
|
+
if (!this._isBrowser) {
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
// The size of the previous axis says nothing about the new one.
|
|
793
|
+
this._viewportSize.set(null);
|
|
794
|
+
this._measureViewport();
|
|
795
|
+
this._scrollPosition = this._currentAxisScroll();
|
|
796
|
+
this._scrollTick.update((v) => v + 1);
|
|
797
|
+
});
|
|
798
|
+
});
|
|
799
|
+
afterNextRender(() => {
|
|
800
|
+
this._engine.initMaxBrowserSize(this._document);
|
|
801
|
+
this._measureViewport();
|
|
802
|
+
this._setupScrollListener();
|
|
803
|
+
this._setupViewportResizeObserver();
|
|
804
|
+
});
|
|
805
|
+
// Runs after the DOM reflects the current window, and re-runs whenever
|
|
806
|
+
// the window or the engine's sizes change.
|
|
807
|
+
afterRenderEffect({
|
|
808
|
+
read: () => {
|
|
809
|
+
// What is wanted and what is rendered change independently: the viewport can move
|
|
810
|
+
// into indices no page covers, and a page can arrive without the viewport moving.
|
|
811
|
+
this._visibleRange();
|
|
812
|
+
this._renderedItems();
|
|
813
|
+
this._engine.version();
|
|
814
|
+
untracked(() => {
|
|
815
|
+
this._scheduleItemMeasurement();
|
|
816
|
+
this._checkDataRequest();
|
|
817
|
+
this._emitStateChange();
|
|
818
|
+
});
|
|
819
|
+
},
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
ngOnDestroy() {
|
|
823
|
+
this._teardown();
|
|
824
|
+
}
|
|
825
|
+
//#region Public API
|
|
826
|
+
/**
|
|
827
|
+
* Resolves when the virtual scroll has settled: the current render pass is
|
|
828
|
+
* complete, the item-size measurements it triggers are complete, and so
|
|
829
|
+
* are the renders those measurements schedule.
|
|
830
|
+
*/
|
|
831
|
+
get layoutComplete() {
|
|
832
|
+
if (!this._layoutCompletePromise) {
|
|
833
|
+
this._layoutCompletePromise = this._resolveLayoutComplete();
|
|
834
|
+
}
|
|
835
|
+
return this._layoutCompletePromise;
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* Scrolls to the specified item index.
|
|
839
|
+
*
|
|
840
|
+
* Items outside the rendered window have only an estimated size, so the
|
|
841
|
+
* first jump can miss the target. The items at the landing point are then
|
|
842
|
+
* measured and the scroll position is corrected, until the offset is
|
|
843
|
+
* stable. The returned promise resolves on that final offset; callers that
|
|
844
|
+
* need only the first, approximate scroll can ignore it.
|
|
845
|
+
*
|
|
846
|
+
* @param index The index of the item to scroll to.
|
|
847
|
+
* @param options `block` / `inline` select the alignment (`start`,
|
|
848
|
+
* `center`, `end` or `nearest`); `behavior` selects `auto` or `smooth`.
|
|
849
|
+
*/
|
|
850
|
+
async scrollToIndex(index, options) {
|
|
851
|
+
const clampedIndex = clamp(index, 0, Math.max(0, this._loaded().totalCount - 1));
|
|
852
|
+
// A newer call supersedes a correction loop that still runs for a
|
|
853
|
+
// previous call, for example under rapid, repeated calls.
|
|
854
|
+
const requestId = ++this._scrollRequestId;
|
|
855
|
+
let offset = this._getAlignedScrollOffset(clampedIndex, options);
|
|
856
|
+
await this._scrollAndWaitForEnd(offset, options?.behavior ?? "auto");
|
|
857
|
+
for (let i = 0; i < MAX_SCROLL_CORRECTION_PASSES; i++) {
|
|
858
|
+
await this.layoutComplete;
|
|
859
|
+
if (requestId !== this._scrollRequestId) {
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
const corrected = this._getAlignedScrollOffset(clampedIndex, options);
|
|
863
|
+
if (Math.abs(corrected - offset) < SCROLL_OFFSET_EPSILON_PX) {
|
|
864
|
+
break;
|
|
865
|
+
}
|
|
866
|
+
offset = corrected;
|
|
867
|
+
await this._scrollAndWaitForEnd(offset, "auto");
|
|
868
|
+
if (requestId !== this._scrollRequestId) {
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
//#endregion
|
|
874
|
+
//#region Scrolling
|
|
875
|
+
/**
|
|
876
|
+
* Re-reads the host's direction, which `dir` can change at runtime. `:dir()`
|
|
877
|
+
* is a selector match, so this costs no layout.
|
|
878
|
+
*/
|
|
879
|
+
_syncDirection() {
|
|
880
|
+
const ltr = isLeftToRight(this._hostRef.nativeElement);
|
|
881
|
+
this._ltr.set(ltr);
|
|
882
|
+
return ltr;
|
|
883
|
+
}
|
|
884
|
+
/** The current real scroll position on the active axis, normalized for RTL. */
|
|
885
|
+
_currentAxisScroll() {
|
|
886
|
+
const host = this._hostRef.nativeElement;
|
|
887
|
+
if (this._isVertical()) {
|
|
888
|
+
return host.scrollTop;
|
|
889
|
+
}
|
|
890
|
+
// Standards-compliant browsers expose a negative scrollLeft in RTL.
|
|
891
|
+
return this._syncDirection() ? host.scrollLeft : -host.scrollLeft;
|
|
892
|
+
}
|
|
893
|
+
/** Applies a scroll offset to the active axis, accounting for RTL. */
|
|
894
|
+
_applyScroll(offset, behavior) {
|
|
895
|
+
const host = this._hostRef.nativeElement;
|
|
896
|
+
if (this._isVertical()) {
|
|
897
|
+
host.scrollTo({ top: offset, behavior });
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
host.scrollTo({ left: this._syncDirection() ? offset : -offset, behavior });
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* The scroll offset that aligns `index` in the viewport according to
|
|
904
|
+
* `options`, from the engine's current size data. As more items are
|
|
905
|
+
* 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
|
+
*/
|
|
911
|
+
_getAlignedScrollOffset(index, options) {
|
|
912
|
+
const requested = this._isVertical()
|
|
913
|
+
? (options?.block ?? "start")
|
|
914
|
+
: (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);
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* Applies a scroll offset to the active axis and waits for the scroll,
|
|
927
|
+
* instant or smooth, to settle.
|
|
928
|
+
*
|
|
929
|
+
* `scrollend` does not fire when the requested offset does not move the
|
|
930
|
+
* scroll position, so that case resolves immediately. The deadline covers
|
|
931
|
+
* an event that never arrives, for example when the element is detached
|
|
932
|
+
* mid-scroll.
|
|
933
|
+
*/
|
|
934
|
+
_scrollAndWaitForEnd(offset, behavior) {
|
|
935
|
+
if (!this._isBrowser ||
|
|
936
|
+
Math.abs(this._currentAxisScroll() - offset) < SCROLL_OFFSET_EPSILON_PX) {
|
|
937
|
+
return Promise.resolve();
|
|
938
|
+
}
|
|
939
|
+
return this._withDeadline(SCROLL_END_TIMEOUT_MS, (abort) => {
|
|
940
|
+
// `scrollend` reports exactly when a scroll has settled. Safari before
|
|
941
|
+
// 18.2 does not have it, and the scroll-idle timer stands in there.
|
|
942
|
+
const settled = "onscrollend" in this._hostRef.nativeElement
|
|
943
|
+
? this._waitForScrollEnd(abort)
|
|
944
|
+
: this._waitForScrollIdle(abort);
|
|
945
|
+
// Applied only after the listener is attached, so an instant scroll
|
|
946
|
+
// cannot settle before something watches for it.
|
|
947
|
+
this._applyScroll(offset, behavior);
|
|
948
|
+
return settled;
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
_waitForScrollEnd(abort) {
|
|
952
|
+
return this._promiseOutsideZone((resolve) => {
|
|
953
|
+
this._hostRef.nativeElement.addEventListener("scrollend", resolve, {
|
|
954
|
+
once: true,
|
|
955
|
+
signal: abort,
|
|
956
|
+
});
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* Resolves when no `scroll` event arrives for `SCROLL_IDLE_MS`: the closest
|
|
961
|
+
* replacement for `scrollend`. The first timer starts immediately, so a
|
|
962
|
+
* scroll that does not move still settles.
|
|
963
|
+
*/
|
|
964
|
+
_waitForScrollIdle(abort) {
|
|
965
|
+
return this._promiseOutsideZone((resolve) => {
|
|
966
|
+
let id = setTimeout(resolve, SCROLL_IDLE_MS);
|
|
967
|
+
this._hostRef.nativeElement.addEventListener("scroll", () => {
|
|
968
|
+
clearTimeout(id);
|
|
969
|
+
id = setTimeout(resolve, SCROLL_IDLE_MS);
|
|
970
|
+
}, { passive: true, signal: abort });
|
|
971
|
+
onAbort(abort, () => clearTimeout(id));
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
//#endregion
|
|
975
|
+
//#region Async helpers
|
|
976
|
+
/** A promise whose subscription work stays out of the Angular zone. */
|
|
977
|
+
_promiseOutsideZone(subscribe) {
|
|
978
|
+
return this._zone.runOutsideAngular(() => new Promise((resolve) => subscribe(() => resolve())));
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Resolves with `task` or with a deadline of `ms`, whichever comes first.
|
|
982
|
+
* The signal then tears down the other, so no live timer or dangling
|
|
983
|
+
* listener remains.
|
|
984
|
+
*/
|
|
985
|
+
_withDeadline(ms, task) {
|
|
986
|
+
const controller = new AbortController();
|
|
987
|
+
return Promise.race([
|
|
988
|
+
task(controller.signal),
|
|
989
|
+
this._promiseOutsideZone((resolve) => {
|
|
990
|
+
const id = setTimeout(resolve, ms);
|
|
991
|
+
onAbort(controller.signal, () => clearTimeout(id));
|
|
992
|
+
}),
|
|
993
|
+
]).finally(() => controller.abort());
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Resolves on the next animation frame, or after `LAYOUT_FRAME_TIMEOUT_MS`
|
|
997
|
+
* when no frame arrives. A hidden tab or a detached element gets no frames
|
|
998
|
+
* and has no layout to wait for, so resolving early there is safe.
|
|
999
|
+
*/
|
|
1000
|
+
_nextFrame() {
|
|
1001
|
+
if (!this._isBrowser) {
|
|
1002
|
+
return Promise.resolve();
|
|
1003
|
+
}
|
|
1004
|
+
return this._withDeadline(LAYOUT_FRAME_TIMEOUT_MS, (abort) => this._promiseOutsideZone((resolve) => {
|
|
1005
|
+
const id = requestAnimationFrame(resolve);
|
|
1006
|
+
onAbort(abort, () => cancelAnimationFrame(id));
|
|
1007
|
+
}));
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Waits out the frames in which the item measurements land. Each
|
|
1011
|
+
* measurement that changes a size bumps the engine's version and schedules
|
|
1012
|
+
* another render, so the layout has settled once the version holds still
|
|
1013
|
+
* across two consecutive frames.
|
|
1014
|
+
*/
|
|
1015
|
+
async _resolveLayoutComplete() {
|
|
1016
|
+
try {
|
|
1017
|
+
let lastVersion = -1;
|
|
1018
|
+
for (let i = 0; i < MAX_LAYOUT_SETTLE_PASSES; i++) {
|
|
1019
|
+
await this._nextFrame();
|
|
1020
|
+
const version = untracked(this._engine.version);
|
|
1021
|
+
if (version === lastVersion) {
|
|
1022
|
+
break;
|
|
1023
|
+
}
|
|
1024
|
+
lastVersion = version;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
finally {
|
|
1028
|
+
// Cleared here, not after the loop, so a run that throws cannot leave
|
|
1029
|
+
// the getter with a permanently rejected promise.
|
|
1030
|
+
this._layoutCompletePromise = null;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
//#endregion
|
|
1034
|
+
//#region Measurement
|
|
1035
|
+
_measureViewport() {
|
|
1036
|
+
const host = this._hostRef.nativeElement;
|
|
1037
|
+
// A host with no box is hidden or detached, not sized: its last measurement is kept so
|
|
1038
|
+
// 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) {
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
const size = this._isVertical() ? host.clientHeight : host.clientWidth;
|
|
1043
|
+
if (size !== untracked(this._viewportSize)) {
|
|
1044
|
+
this._viewportSize.set(size);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
_setupViewportResizeObserver() {
|
|
1048
|
+
this._viewportResizeObserver?.disconnect();
|
|
1049
|
+
this._zone.runOutsideAngular(() => {
|
|
1050
|
+
this._viewportResizeObserver = new ResizeObserver(() => this._measureViewport());
|
|
1051
|
+
this._viewportResizeObserver.observe(this._hostRef.nativeElement);
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
_setupScrollListener() {
|
|
1055
|
+
const host = this._hostRef.nativeElement;
|
|
1056
|
+
if (this._onScroll) {
|
|
1057
|
+
host.removeEventListener("scroll", this._onScroll);
|
|
1058
|
+
}
|
|
1059
|
+
this._zone.runOutsideAngular(() => {
|
|
1060
|
+
this._onScroll = () => this._handleScroll();
|
|
1061
|
+
host.addEventListener("scroll", this._onScroll, { passive: true });
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* Records the new scroll offset and invalidates the rendered window only
|
|
1066
|
+
* when it actually moves. Without the guard, a scroll inside a single item
|
|
1067
|
+
* would schedule a change detection pass for an identical result.
|
|
1068
|
+
*/
|
|
1069
|
+
_handleScroll() {
|
|
1070
|
+
this._scrollPosition = this._currentAxisScroll();
|
|
1071
|
+
const next = this._engine.getVisibleRange(this._scrollPosition, untracked(this._effectiveViewportSize), untracked(this._normalizedOverScan));
|
|
1072
|
+
if (!rangesEqual(next, untracked(this._visibleRange))) {
|
|
1073
|
+
this._scrollTick.update((v) => v + 1);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
_handleItemResize(entries) {
|
|
1077
|
+
for (const entry of entries) {
|
|
1078
|
+
const index = itemIndex(entry.target);
|
|
1079
|
+
if (index < 0) {
|
|
1080
|
+
continue;
|
|
1081
|
+
}
|
|
1082
|
+
const measured = this._isVertical()
|
|
1083
|
+
? (entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height)
|
|
1084
|
+
: (entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width);
|
|
1085
|
+
if (measured > 0) {
|
|
1086
|
+
this._engine.measureItem(index, measured);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
/**
|
|
1091
|
+
* Synchronizes the item observer with the rendered window, applying only
|
|
1092
|
+
* the difference. A newly observed element gets one initial measurement.
|
|
1093
|
+
*
|
|
1094
|
+
* 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.
|
|
1099
|
+
*/
|
|
1100
|
+
_scheduleItemMeasurement() {
|
|
1101
|
+
const content = this._contentDivRef()?.nativeElement;
|
|
1102
|
+
if (!content) {
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
const observer = this._getItemResizeObserver();
|
|
1106
|
+
for (const element of [...this._observedItems]) {
|
|
1107
|
+
if (element.parentNode !== content) {
|
|
1108
|
+
observer.unobserve(element);
|
|
1109
|
+
this._observedItems.delete(element);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
for (const element of Array.from(content.children)) {
|
|
1113
|
+
const index = itemIndex(element);
|
|
1114
|
+
if (this._observedItems.has(element)) {
|
|
1115
|
+
if (this._observedItemIndexes.get(element) === index) {
|
|
1116
|
+
continue;
|
|
1117
|
+
}
|
|
1118
|
+
observer.unobserve(element);
|
|
1119
|
+
}
|
|
1120
|
+
observer.observe(element);
|
|
1121
|
+
this._observedItems.add(element);
|
|
1122
|
+
this._observedItemIndexes.set(element, index);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* 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
|
|
1128
|
+
* change reports nothing on its own.
|
|
1129
|
+
*/
|
|
1130
|
+
_remeasureFrom(index) {
|
|
1131
|
+
for (const element of this._observedItems) {
|
|
1132
|
+
if ((this._observedItemIndexes.get(element) ?? -1) >= index) {
|
|
1133
|
+
this._observedItemIndexes.delete(element);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
_getItemResizeObserver() {
|
|
1138
|
+
if (!this._itemResizeObserver) {
|
|
1139
|
+
this._itemResizeObserver = this._zone.runOutsideAngular(() => new ResizeObserver((entries) => this._handleItemResize(entries)));
|
|
1140
|
+
}
|
|
1141
|
+
return this._itemResizeObserver;
|
|
1142
|
+
}
|
|
1143
|
+
//#endregion
|
|
1144
|
+
//#region Events
|
|
1145
|
+
/**
|
|
1146
|
+
* The number of leading items that kept their identity across a `data`
|
|
1147
|
+
* change: the index of the first item whose measured size no longer
|
|
1148
|
+
* matches its rendered content. An append (the `dataRequest` flow) retains
|
|
1149
|
+
* all items. A filter or a replacement retains only the unchanged prefix.
|
|
1150
|
+
*/
|
|
1151
|
+
_firstChangedIndex(previous, current) {
|
|
1152
|
+
const shared = Math.min(previous.length, current.length);
|
|
1153
|
+
for (let i = 0; i < shared; i++) {
|
|
1154
|
+
if (previous[i] !== current[i]) {
|
|
1155
|
+
return i;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
return shared;
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
1161
|
+
* How many leading items keep their measured size across a change. A page of the same
|
|
1162
|
+
* collection keeps all of them - its indices still mean the same records. A page with
|
|
1163
|
+
* another `totalCount` comes from a filtered or otherwise different collection, and
|
|
1164
|
+
* switching inputs starts over, so neither keeps any.
|
|
1165
|
+
*/
|
|
1166
|
+
_retainCount(previous, current) {
|
|
1167
|
+
if (previous?.windowed !== current.windowed) {
|
|
1168
|
+
return 0;
|
|
1169
|
+
}
|
|
1170
|
+
if (!current.windowed) {
|
|
1171
|
+
return this._firstChangedIndex(previous.items, current.items);
|
|
1172
|
+
}
|
|
1173
|
+
return previous.totalCount === current.totalCount ? current.totalCount : 0;
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Emits `stateChange`. Skipped when the window is empty or equal to the
|
|
1177
|
+
* last reported one, because measurement passes re-render without a window
|
|
1178
|
+
* change.
|
|
1179
|
+
*/
|
|
1180
|
+
_emitStateChange() {
|
|
1181
|
+
const { startIndex, endIndex } = untracked(this._visibleRange);
|
|
1182
|
+
if (endIndex < startIndex) {
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
const state = {
|
|
1186
|
+
startIndex,
|
|
1187
|
+
endIndex,
|
|
1188
|
+
viewportSize: untracked(this._effectiveViewportSize),
|
|
1189
|
+
totalSize: untracked(this._engine.totalSize),
|
|
1190
|
+
};
|
|
1191
|
+
if (statesEqual(this._lastEmittedState, state)) {
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
this._lastEmittedState = { ...state };
|
|
1195
|
+
this.stateChange.emit(state);
|
|
1196
|
+
}
|
|
1197
|
+
_checkDataRequest() {
|
|
1198
|
+
const loaded = untracked(this._loaded);
|
|
1199
|
+
// `dataRequest` asks for items to append, which a sized collection does not need. The
|
|
1200
|
+
// count `data` had reached before says nothing once a window is bound, so forget it here.
|
|
1201
|
+
if (loaded.windowed) {
|
|
1202
|
+
this._lastDataRequestIndex = -1;
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
if (this._hasPendingDataRequest) {
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
const { endIndex } = untracked(this._visibleRange);
|
|
1209
|
+
const total = loaded.items.length;
|
|
1210
|
+
if (total === 0 || endIndex < total - DATA_REQUEST_THRESHOLD) {
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
// Each `data` change clears `_hasPendingDataRequest`, including one that
|
|
1214
|
+
// appends nothing. Without this second guard, a consumer whose source is
|
|
1215
|
+
// exhausted, and that reassigns `data` in response to a request, would
|
|
1216
|
+
// receive the same request on each reassignment.
|
|
1217
|
+
if (this._lastDataRequestIndex === total) {
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
this._hasPendingDataRequest = true;
|
|
1221
|
+
this._lastDataRequestIndex = total;
|
|
1222
|
+
this.dataRequest.emit({
|
|
1223
|
+
startIndex: total,
|
|
1224
|
+
count: Math.max(untracked(this._normalizedOverScan) * DATA_REQUEST_OVER_SCAN_FACTOR, DATA_REQUEST_MIN_COUNT),
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
//#endregion
|
|
1228
|
+
_teardown() {
|
|
1229
|
+
const host = this._hostRef.nativeElement;
|
|
1230
|
+
if (this._onScroll) {
|
|
1231
|
+
host.removeEventListener("scroll", this._onScroll);
|
|
1232
|
+
this._onScroll = null;
|
|
1233
|
+
}
|
|
1234
|
+
this._viewportResizeObserver?.disconnect();
|
|
1235
|
+
this._viewportResizeObserver = null;
|
|
1236
|
+
this._itemResizeObserver?.disconnect();
|
|
1237
|
+
this._itemResizeObserver = null;
|
|
1238
|
+
this._observedItems.clear();
|
|
1239
|
+
}
|
|
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 }); }
|
|
1242
|
+
}
|
|
1243
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: IgxVirtualScrollComponent, decorators: [{
|
|
1244
|
+
type: Component,
|
|
1245
|
+
args: [{ selector: "igx-virtual-scroll", changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, imports: [NgTemplateOutlet], host: {
|
|
1246
|
+
class: "igx-virtual-scroll",
|
|
1247
|
+
role: "list",
|
|
1248
|
+
"[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"] }] } });
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* Generated bundle index. Do not edit.
|
|
1254
|
+
*/
|
|
1255
|
+
|
|
1256
|
+
export { IgxVirtualItemDirective, IgxVirtualScrollComponent, IgxVsItemContext };
|
|
1257
|
+
//# sourceMappingURL=igniteui-angular-virtual-scroll.mjs.map
|