selective-ui 1.1.5 → 1.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 +2 -0
- package/dist/selective-ui.css +7 -1
- package/dist/selective-ui.css.map +1 -1
- package/dist/selective-ui.esm.js +750 -39
- package/dist/selective-ui.esm.js.map +1 -1
- package/dist/selective-ui.esm.min.js +2 -2
- package/dist/selective-ui.esm.min.js.br +0 -0
- package/dist/selective-ui.min.css +1 -1
- package/dist/selective-ui.min.css.br +0 -0
- package/dist/selective-ui.min.js +2 -2
- package/dist/selective-ui.min.js.br +0 -0
- package/dist/selective-ui.umd.js +751 -40
- package/dist/selective-ui.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/css/components/popup.css +7 -1
- package/src/ts/adapter/mixed-adapter.ts +12 -7
- package/src/ts/components/empty-state.ts +5 -4
- package/src/ts/components/loading-state.ts +4 -4
- package/src/ts/components/option-handle.ts +4 -4
- package/src/ts/components/popup.ts +42 -16
- package/src/ts/components/searchbox.ts +2 -0
- package/src/ts/components/selectbox.ts +9 -2
- package/src/ts/core/base/adapter.ts +8 -5
- package/src/ts/core/base/model.ts +19 -1
- package/src/ts/core/base/recyclerview.ts +3 -1
- package/src/ts/core/base/virtual-recyclerview.ts +762 -0
- package/src/ts/core/model-manager.ts +24 -16
- package/src/ts/core/search-controller.ts +4 -4
- package/src/ts/global.ts +5 -5
- package/src/ts/index.ts +4 -4
- package/src/ts/services/effector.ts +2 -2
- package/src/ts/types/components/state.box.type.ts +1 -18
- package/src/ts/types/core/base/adapter.type.ts +14 -0
- package/src/ts/types/core/base/model.type.ts +5 -0
- package/src/ts/types/core/base/recyclerview.type.ts +3 -1
- package/src/ts/types/core/base/view.type.ts +6 -0
- package/src/ts/types/core/base/virtual-recyclerview.type.ts +66 -0
- package/src/ts/types/utils/istorage.type.ts +1 -0
- package/src/ts/utils/istorage.ts +3 -2
- package/src/ts/utils/libs.ts +26 -25
- package/src/ts/utils/selective.ts +7 -7
- package/src/ts/views/option-view.ts +8 -8
package/dist/selective-ui.esm.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! Selective UI v1.
|
|
1
|
+
/*! Selective UI v1.2.0 | MIT License */
|
|
2
2
|
/**
|
|
3
3
|
* @class
|
|
4
4
|
*/
|
|
@@ -6,6 +6,7 @@ class iStorage {
|
|
|
6
6
|
constructor() {
|
|
7
7
|
this.defaultConfig = {
|
|
8
8
|
showPanel: true,
|
|
9
|
+
virtualScroll: true,
|
|
9
10
|
accessoryStyle: "top",
|
|
10
11
|
multiple: false,
|
|
11
12
|
minWidth: "50px",
|
|
@@ -402,8 +403,8 @@ class Libs {
|
|
|
402
403
|
* matching element properties or data-* attributes when present.
|
|
403
404
|
*
|
|
404
405
|
* @param {HTMLElement} element - Source element providing overrides.
|
|
405
|
-
* @param {
|
|
406
|
-
* @returns {
|
|
406
|
+
* @param {SelectiveOptions} options - Default configuration to be merged.
|
|
407
|
+
* @returns {SelectiveOptions} - Final configuration after element overrides.
|
|
407
408
|
*/
|
|
408
409
|
static buildConfig(element, options) {
|
|
409
410
|
const myOptions = this.jsCopyObject(options);
|
|
@@ -497,7 +498,7 @@ class Libs {
|
|
|
497
498
|
* Sets or updates the binder map entry for a given element.
|
|
498
499
|
*
|
|
499
500
|
* @param {HTMLElement} item - Element key to associate with the binder map.
|
|
500
|
-
* @param {
|
|
501
|
+
* @param {BinderMap} bindMap - Value to store in the binder map.
|
|
501
502
|
*/
|
|
502
503
|
static setBinderMap(item, bindMap) {
|
|
503
504
|
this.iStorage.bindedMap.set(item, bindMap);
|
|
@@ -524,7 +525,7 @@ class Libs {
|
|
|
524
525
|
* Sets or updates the unbinder map entry for a given element.
|
|
525
526
|
*
|
|
526
527
|
* @param {HTMLElement} item - Element key to associate with the unbinder map.
|
|
527
|
-
* @param {
|
|
528
|
+
* @param {BinderMap} bindMap - Value to store in the unbinder map.
|
|
528
529
|
*/
|
|
529
530
|
static setUnbinderMap(item, bindMap) {
|
|
530
531
|
this.iStorage.unbindedMap.set(item, bindMap);
|
|
@@ -629,7 +630,7 @@ class Libs {
|
|
|
629
630
|
const group = child;
|
|
630
631
|
result.push(group);
|
|
631
632
|
Array.from(group.children).forEach((option) => {
|
|
632
|
-
option
|
|
633
|
+
option["__parentGroup"] = group;
|
|
633
634
|
result.push(option);
|
|
634
635
|
});
|
|
635
636
|
}
|
|
@@ -1269,6 +1270,11 @@ class Popup {
|
|
|
1269
1270
|
this._optionsContainer = null;
|
|
1270
1271
|
this._scrollListener = null;
|
|
1271
1272
|
this._hideLoadHandle = null;
|
|
1273
|
+
this.virtualScrollConfig = {
|
|
1274
|
+
estimateItemHeight: 36,
|
|
1275
|
+
overscan: 8,
|
|
1276
|
+
dynamicHeights: true
|
|
1277
|
+
};
|
|
1272
1278
|
this._modelManager = modelManager;
|
|
1273
1279
|
if (select && options) {
|
|
1274
1280
|
this.init(select, options);
|
|
@@ -1313,8 +1319,16 @@ class Popup {
|
|
|
1313
1319
|
this._optionsContainer = nodeMounted.tags.OptionsContainer;
|
|
1314
1320
|
this._parent = Libs.getBinderMap(select);
|
|
1315
1321
|
this.options = options;
|
|
1322
|
+
const recyclerViewOpt = options.virtualScroll
|
|
1323
|
+
? {
|
|
1324
|
+
scrollEl: this.node,
|
|
1325
|
+
estimateItemHeight: this.virtualScrollConfig.estimateItemHeight,
|
|
1326
|
+
overscan: this.virtualScrollConfig.overscan,
|
|
1327
|
+
dynamicHeights: this.virtualScrollConfig.dynamicHeights
|
|
1328
|
+
}
|
|
1329
|
+
: {};
|
|
1316
1330
|
// Load ModelManager resources into container
|
|
1317
|
-
this._modelManager.load(this._optionsContainer, { isMultiple: options.multiple });
|
|
1331
|
+
this._modelManager.load(this._optionsContainer, { isMultiple: options.multiple }, recyclerViewOpt);
|
|
1318
1332
|
const MMResources = this._modelManager.getResources();
|
|
1319
1333
|
this.optionAdapter = MMResources.adapter;
|
|
1320
1334
|
this.recyclerView = MMResources.recyclerView;
|
|
@@ -1338,9 +1352,10 @@ class Popup {
|
|
|
1338
1352
|
this._modelManager.skipEvent(true);
|
|
1339
1353
|
if (Libs.string2Boolean(this.options.loadingfield) === false)
|
|
1340
1354
|
return;
|
|
1341
|
-
this._updateEmptyState({
|
|
1355
|
+
// this._updateEmptyState({isEmpty: false, hasVisible: true});
|
|
1356
|
+
this.emptyState.hide();
|
|
1342
1357
|
this.loadingState.show(this.optionAdapter.getVisibilityStats().hasVisible);
|
|
1343
|
-
this.optionHandle.hide();
|
|
1358
|
+
// this.optionHandle.hide();
|
|
1344
1359
|
this.triggerResize();
|
|
1345
1360
|
}
|
|
1346
1361
|
/**
|
|
@@ -1462,6 +1477,8 @@ class Popup {
|
|
|
1462
1477
|
};
|
|
1463
1478
|
this._resizeObser.connect(this._parent.container.tags.ViewPanel);
|
|
1464
1479
|
callback?.();
|
|
1480
|
+
const rv = this.recyclerView;
|
|
1481
|
+
rv?.resume?.();
|
|
1465
1482
|
},
|
|
1466
1483
|
});
|
|
1467
1484
|
}
|
|
@@ -1472,6 +1489,8 @@ class Popup {
|
|
|
1472
1489
|
close(callback = null) {
|
|
1473
1490
|
if (!this.isCreated || !this.options || !this._resizeObser || !this._effSvc)
|
|
1474
1491
|
return;
|
|
1492
|
+
const rv = this.recyclerView;
|
|
1493
|
+
rv?.suspend?.();
|
|
1475
1494
|
this._resizeObser.disconnect();
|
|
1476
1495
|
this._effSvc.collapse({
|
|
1477
1496
|
duration: this.options.animationtime,
|
|
@@ -1583,15 +1602,11 @@ class Popup {
|
|
|
1583
1602
|
const viewPanel = this._parent.container.tags.ViewPanel;
|
|
1584
1603
|
const rect = viewPanel.getBoundingClientRect();
|
|
1585
1604
|
const style = window.getComputedStyle(viewPanel);
|
|
1586
|
-
const vv = window.visualViewport;
|
|
1587
|
-
const is_ios = Libs.IsIOS();
|
|
1588
|
-
const viewportOffsetY = vv && is_ios ? vv.offsetTop : 0;
|
|
1589
|
-
const viewportOffsetX = vv && is_ios ? vv.offsetLeft : 0;
|
|
1590
1605
|
return {
|
|
1591
1606
|
width: rect.width,
|
|
1592
1607
|
height: rect.height,
|
|
1593
|
-
top: rect.top
|
|
1594
|
-
left: rect.left
|
|
1608
|
+
top: rect.top,
|
|
1609
|
+
left: rect.left,
|
|
1595
1610
|
padding: {
|
|
1596
1611
|
top: parseFloat(style.paddingTop),
|
|
1597
1612
|
right: parseFloat(style.paddingRight),
|
|
@@ -1614,7 +1629,6 @@ class Popup {
|
|
|
1614
1629
|
const vv = window.visualViewport;
|
|
1615
1630
|
const is_ios = Libs.IsIOS();
|
|
1616
1631
|
const viewportHeight = vv?.height ?? window.innerHeight;
|
|
1617
|
-
const viewportOffsetY = vv && is_ios ? vv.offsetTop : 0;
|
|
1618
1632
|
const gap = 3;
|
|
1619
1633
|
const safeMargin = 15;
|
|
1620
1634
|
const dimensions = this._effSvc.getHiddenDimensions("flex");
|
|
@@ -1646,6 +1660,7 @@ class Popup {
|
|
|
1646
1660
|
}
|
|
1647
1661
|
}
|
|
1648
1662
|
realHeight = Math.min(contentHeight, maxHeight);
|
|
1663
|
+
const viewportOffsetY = vv && is_ios ? vv.offsetTop : 0;
|
|
1649
1664
|
const top = position === "bottom"
|
|
1650
1665
|
? location.top + location.height + gap + viewportOffsetY
|
|
1651
1666
|
: location.top - realHeight - gap + viewportOffsetY;
|
|
@@ -1754,6 +1769,7 @@ class SearchBox {
|
|
|
1754
1769
|
isControlKey = true;
|
|
1755
1770
|
this.onEsc?.();
|
|
1756
1771
|
}
|
|
1772
|
+
e.stopPropagation();
|
|
1757
1773
|
});
|
|
1758
1774
|
inputEl.addEventListener("input", () => {
|
|
1759
1775
|
if (isControlKey)
|
|
@@ -2143,11 +2159,11 @@ class Model {
|
|
|
2143
2159
|
* @param {TView|null} [view=null] - The associated view responsible for rendering the model.
|
|
2144
2160
|
*/
|
|
2145
2161
|
constructor(options, targetElement = null, view = null) {
|
|
2146
|
-
/** @type {TTarget | null} */
|
|
2147
2162
|
this.targetElement = null;
|
|
2148
2163
|
this.view = null;
|
|
2149
2164
|
this.position = -1;
|
|
2150
2165
|
this.isInit = false;
|
|
2166
|
+
this.isRemoved = false;
|
|
2151
2167
|
this.options = options;
|
|
2152
2168
|
this.targetElement = targetElement;
|
|
2153
2169
|
this.view = view;
|
|
@@ -2161,11 +2177,26 @@ class Model {
|
|
|
2161
2177
|
this.targetElement = targetElement;
|
|
2162
2178
|
this.onTargetChanged();
|
|
2163
2179
|
}
|
|
2180
|
+
/**
|
|
2181
|
+
* Cleans up references and invokes the removal hook when the model is no longer needed.
|
|
2182
|
+
*/
|
|
2183
|
+
remove() {
|
|
2184
|
+
this.targetElement = null;
|
|
2185
|
+
this.view?.getView()?.remove?.();
|
|
2186
|
+
this.view = null;
|
|
2187
|
+
this.isRemoved = true;
|
|
2188
|
+
this.onRemove();
|
|
2189
|
+
}
|
|
2164
2190
|
/**
|
|
2165
2191
|
* Hook invoked whenever the target element changes.
|
|
2166
2192
|
* Override in subclasses to react to attribute/content updates (e.g., text, disabled state).
|
|
2167
2193
|
*/
|
|
2168
2194
|
onTargetChanged() { }
|
|
2195
|
+
/**
|
|
2196
|
+
* Hook invoked whenever the target element is removed.
|
|
2197
|
+
* Override in subclasses to react to removal of the element.
|
|
2198
|
+
*/
|
|
2199
|
+
onRemove() { }
|
|
2169
2200
|
}
|
|
2170
2201
|
|
|
2171
2202
|
/**
|
|
@@ -2615,7 +2646,7 @@ class ModelManager {
|
|
|
2615
2646
|
// Adapter expects TModel[], but this manager's list is GroupModel|OptionModel.
|
|
2616
2647
|
this._privAdapterHandle.syncFromSource(this._privModelList);
|
|
2617
2648
|
}
|
|
2618
|
-
this.refresh();
|
|
2649
|
+
this.refresh(false);
|
|
2619
2650
|
}
|
|
2620
2651
|
/**
|
|
2621
2652
|
* Requests a view refresh if an adapter has been initialized,
|
|
@@ -2624,7 +2655,7 @@ class ModelManager {
|
|
|
2624
2655
|
notify() {
|
|
2625
2656
|
if (!this._privAdapterHandle)
|
|
2626
2657
|
return;
|
|
2627
|
-
this.refresh();
|
|
2658
|
+
this.refresh(false);
|
|
2628
2659
|
}
|
|
2629
2660
|
/**
|
|
2630
2661
|
* Initializes adapter and recycler view instances, attaches them to a container element,
|
|
@@ -2634,8 +2665,8 @@ class ModelManager {
|
|
|
2634
2665
|
this._privAdapterHandle = new this._privAdapter(this._privModelList);
|
|
2635
2666
|
Object.assign(this._privAdapterHandle, adapterOpt);
|
|
2636
2667
|
this._privRecyclerViewHandle = new this._privRecyclerView(viewElement);
|
|
2637
|
-
this._privRecyclerViewHandle.setAdapter(this._privAdapterHandle);
|
|
2638
2668
|
Object.assign(this._privRecyclerViewHandle, recyclerViewOpt);
|
|
2669
|
+
this._privRecyclerViewHandle.setAdapter(this._privAdapterHandle);
|
|
2639
2670
|
}
|
|
2640
2671
|
/**
|
|
2641
2672
|
* Diffs existing models against new <optgroup>/<option> data to update in place:
|
|
@@ -2716,18 +2747,21 @@ class ModelManager {
|
|
|
2716
2747
|
position++;
|
|
2717
2748
|
}
|
|
2718
2749
|
});
|
|
2750
|
+
let isUpdate = true;
|
|
2719
2751
|
oldGroupMap.forEach((removedGroup) => {
|
|
2720
|
-
|
|
2752
|
+
isUpdate = false;
|
|
2753
|
+
removedGroup.remove();
|
|
2721
2754
|
});
|
|
2722
2755
|
oldOptionMap.forEach((removedOption) => {
|
|
2723
|
-
|
|
2756
|
+
isUpdate = false;
|
|
2757
|
+
removedOption.remove();
|
|
2724
2758
|
});
|
|
2725
2759
|
this._privModelList = newModels;
|
|
2726
2760
|
if (this._privAdapterHandle) {
|
|
2727
2761
|
this._privAdapterHandle.updateData(this._privModelList);
|
|
2728
2762
|
}
|
|
2729
2763
|
this.onUpdated();
|
|
2730
|
-
this.refresh();
|
|
2764
|
+
this.refresh(isUpdate);
|
|
2731
2765
|
}
|
|
2732
2766
|
/**
|
|
2733
2767
|
* Hook invoked after the manager completes an update or refresh cycle.
|
|
@@ -2746,11 +2780,13 @@ class ModelManager {
|
|
|
2746
2780
|
/**
|
|
2747
2781
|
* Re-renders the recycler view if present and invokes the post-refresh hook.
|
|
2748
2782
|
* No-op if the recycler view is not initialized.
|
|
2783
|
+
*
|
|
2784
|
+
* @param isUpdate - Indicates if this refresh is due to an update operation.
|
|
2749
2785
|
*/
|
|
2750
|
-
refresh() {
|
|
2786
|
+
refresh(isUpdate) {
|
|
2751
2787
|
if (!this._privRecyclerViewHandle)
|
|
2752
2788
|
return;
|
|
2753
|
-
this._privRecyclerViewHandle.refresh();
|
|
2789
|
+
this._privRecyclerViewHandle.refresh(isUpdate);
|
|
2754
2790
|
this.onUpdated();
|
|
2755
2791
|
}
|
|
2756
2792
|
/**
|
|
@@ -2843,8 +2879,10 @@ class RecyclerView {
|
|
|
2843
2879
|
/**
|
|
2844
2880
|
* Forces a re-render of the current adapter state into the container.
|
|
2845
2881
|
* Useful when visual updates are required without changing the data.
|
|
2882
|
+
*
|
|
2883
|
+
* @param isUpdate - Indicates if this refresh is due to an update operation.
|
|
2846
2884
|
*/
|
|
2847
|
-
refresh() {
|
|
2885
|
+
refresh(isUpdate) {
|
|
2848
2886
|
this.render();
|
|
2849
2887
|
}
|
|
2850
2888
|
}
|
|
@@ -3588,11 +3626,11 @@ class Adapter {
|
|
|
3588
3626
|
*/
|
|
3589
3627
|
onViewHolder(item, viewer, position) {
|
|
3590
3628
|
const v = viewer;
|
|
3591
|
-
if (
|
|
3592
|
-
v?.
|
|
3629
|
+
if (item.isInit) {
|
|
3630
|
+
v?.update?.();
|
|
3593
3631
|
}
|
|
3594
3632
|
else {
|
|
3595
|
-
v?.
|
|
3633
|
+
v?.render?.();
|
|
3596
3634
|
}
|
|
3597
3635
|
}
|
|
3598
3636
|
/**
|
|
@@ -4459,14 +4497,18 @@ class MixedAdapter extends Adapter {
|
|
|
4459
4497
|
}
|
|
4460
4498
|
for (let i = index; i < this.flatOptions.length; i++) {
|
|
4461
4499
|
const item = this.flatOptions[i];
|
|
4462
|
-
if (!item
|
|
4500
|
+
if (!item?.visible)
|
|
4463
4501
|
continue;
|
|
4464
4502
|
item.highlighted = true;
|
|
4465
4503
|
this._currentHighlightIndex = i;
|
|
4466
4504
|
if (isScrollToView) {
|
|
4467
4505
|
const el = item.view?.getView?.();
|
|
4468
|
-
if (el)
|
|
4469
|
-
el.scrollIntoView({ block:
|
|
4506
|
+
if (el) {
|
|
4507
|
+
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
4508
|
+
}
|
|
4509
|
+
else {
|
|
4510
|
+
this.recyclerView?.ensureRendered?.(i, { scrollIntoView: true });
|
|
4511
|
+
}
|
|
4470
4512
|
}
|
|
4471
4513
|
this.onHighlightChange(i, item.view?.getView?.()?.id);
|
|
4472
4514
|
return;
|
|
@@ -4484,6 +4526,671 @@ class MixedAdapter extends Adapter {
|
|
|
4484
4526
|
onCollapsedChange(model, collapsed) { }
|
|
4485
4527
|
}
|
|
4486
4528
|
|
|
4529
|
+
/**
|
|
4530
|
+
* Fenwick tree (Binary Indexed Tree) for efficient prefix sum queries.
|
|
4531
|
+
* Supports O(log n) update and query operations for cumulative item heights.
|
|
4532
|
+
* Uses 1-based indexing internally for BIT operations.
|
|
4533
|
+
*/
|
|
4534
|
+
class Fenwick {
|
|
4535
|
+
constructor(n = 0) {
|
|
4536
|
+
this.bit = [];
|
|
4537
|
+
this.n = 0;
|
|
4538
|
+
this.reset(n);
|
|
4539
|
+
}
|
|
4540
|
+
/** Resets tree to new size, clearing all values. */
|
|
4541
|
+
reset(n) {
|
|
4542
|
+
this.n = n;
|
|
4543
|
+
this.bit = new Array(n + 1).fill(0);
|
|
4544
|
+
}
|
|
4545
|
+
/** Adds delta to element at 1-based index i. */
|
|
4546
|
+
add(i, delta) {
|
|
4547
|
+
for (let x = i; x <= this.n; x += x & -x)
|
|
4548
|
+
this.bit[x] += delta;
|
|
4549
|
+
}
|
|
4550
|
+
/** Returns prefix sum for range [1..i]. */
|
|
4551
|
+
sum(i) {
|
|
4552
|
+
let s = 0;
|
|
4553
|
+
for (let x = i; x > 0; x -= x & -x)
|
|
4554
|
+
s += this.bit[x];
|
|
4555
|
+
return s;
|
|
4556
|
+
}
|
|
4557
|
+
/** Returns sum in range [l..r] (1-based, inclusive). */
|
|
4558
|
+
rangeSum(l, r) {
|
|
4559
|
+
return r < l ? 0 : this.sum(r) - this.sum(l - 1);
|
|
4560
|
+
}
|
|
4561
|
+
/** Builds tree from 0-based array in O(n log n). */
|
|
4562
|
+
buildFrom(arr) {
|
|
4563
|
+
this.reset(arr.length);
|
|
4564
|
+
arr.forEach((val, i) => this.add(i + 1, val));
|
|
4565
|
+
}
|
|
4566
|
+
/**
|
|
4567
|
+
* Binary search to find largest index where prefix sum <= target.
|
|
4568
|
+
* Returns count of items that fit within target height.
|
|
4569
|
+
*/
|
|
4570
|
+
lowerBoundPrefix(target) {
|
|
4571
|
+
let idx = 0, bitMask = 1;
|
|
4572
|
+
while (bitMask << 1 <= this.n)
|
|
4573
|
+
bitMask <<= 1;
|
|
4574
|
+
let cur = 0;
|
|
4575
|
+
for (let step = bitMask; step !== 0; step >>= 1) {
|
|
4576
|
+
const next = idx + step;
|
|
4577
|
+
if (next <= this.n && cur + this.bit[next] <= target) {
|
|
4578
|
+
idx = next;
|
|
4579
|
+
cur += this.bit[next];
|
|
4580
|
+
}
|
|
4581
|
+
}
|
|
4582
|
+
return idx;
|
|
4583
|
+
}
|
|
4584
|
+
}
|
|
4585
|
+
/**
|
|
4586
|
+
* Virtual RecyclerView with efficient windowing and dynamic height support.
|
|
4587
|
+
*
|
|
4588
|
+
* Only renders items visible in viewport plus overscan buffer, using padding
|
|
4589
|
+
* elements to simulate scroll height. Supports variable item heights with
|
|
4590
|
+
* adaptive estimation and maintains scroll position during height changes.
|
|
4591
|
+
*
|
|
4592
|
+
* @template TItem - Model type for list items
|
|
4593
|
+
* @template TAdapter - Adapter managing item views
|
|
4594
|
+
*/
|
|
4595
|
+
class VirtualRecyclerView extends RecyclerView {
|
|
4596
|
+
/** Creates virtual recycler view with optional root element. */
|
|
4597
|
+
constructor(viewElement = null) {
|
|
4598
|
+
super(viewElement);
|
|
4599
|
+
this.opts = {
|
|
4600
|
+
scrollEl: undefined,
|
|
4601
|
+
estimateItemHeight: 36,
|
|
4602
|
+
overscan: 8,
|
|
4603
|
+
dynamicHeights: true,
|
|
4604
|
+
adaptiveEstimate: true,
|
|
4605
|
+
};
|
|
4606
|
+
this.heightCache = [];
|
|
4607
|
+
this.fenwick = new Fenwick(0);
|
|
4608
|
+
this.created = new Map();
|
|
4609
|
+
this.firstMeasured = false;
|
|
4610
|
+
this.start = 0;
|
|
4611
|
+
this.end = -1;
|
|
4612
|
+
this._rafId = null;
|
|
4613
|
+
this._measureRaf = null;
|
|
4614
|
+
this._updating = false;
|
|
4615
|
+
this._suppressResize = false;
|
|
4616
|
+
this._lastRenderCount = 0;
|
|
4617
|
+
this._suspended = false;
|
|
4618
|
+
this._resumeResizeAfter = false;
|
|
4619
|
+
this._stickyCacheTick = 0;
|
|
4620
|
+
this._stickyCacheVal = 0;
|
|
4621
|
+
this.measuredSum = 0;
|
|
4622
|
+
this.measuredCount = 0;
|
|
4623
|
+
}
|
|
4624
|
+
/** Updates virtualization settings (overscan, heights, etc). */
|
|
4625
|
+
configure(opts) {
|
|
4626
|
+
this.opts = { ...this.opts, ...opts };
|
|
4627
|
+
}
|
|
4628
|
+
/**
|
|
4629
|
+
* Binds adapter and initializes virtualization scaffold.
|
|
4630
|
+
* Removes previous adapter if exists, sets up scroll listeners and DOM structure.
|
|
4631
|
+
*/
|
|
4632
|
+
setAdapter(adapter) {
|
|
4633
|
+
if (this.adapter)
|
|
4634
|
+
this.dispose();
|
|
4635
|
+
super.setAdapter(adapter);
|
|
4636
|
+
adapter.recyclerView = this;
|
|
4637
|
+
if (!this.viewElement)
|
|
4638
|
+
return;
|
|
4639
|
+
this.viewElement.replaceChildren();
|
|
4640
|
+
const nodeMounted = Libs.mountNode({
|
|
4641
|
+
PadTop: { tag: { node: "div", classList: "selective-ui-virtual-pad-top" } },
|
|
4642
|
+
ItemsHost: { tag: { node: "div", classList: "selective-ui-virtual-items" } },
|
|
4643
|
+
PadBottom: { tag: { node: "div", classList: "selective-ui-virtual-pad-bottom" } },
|
|
4644
|
+
}, this.viewElement);
|
|
4645
|
+
this.PadTop = nodeMounted.PadTop;
|
|
4646
|
+
this.ItemsHost = nodeMounted.ItemsHost;
|
|
4647
|
+
this.PadBottom = nodeMounted.PadBottom;
|
|
4648
|
+
this.scrollEl = this.opts.scrollEl
|
|
4649
|
+
?? this.viewElement.closest(".selective-ui-popup")
|
|
4650
|
+
?? this.viewElement.parentElement;
|
|
4651
|
+
if (!this.scrollEl)
|
|
4652
|
+
throw new Error("VirtualRecyclerView: scrollEl not found");
|
|
4653
|
+
this._boundOnScroll = this.onScroll.bind(this);
|
|
4654
|
+
this.scrollEl.addEventListener("scroll", this._boundOnScroll, { passive: true });
|
|
4655
|
+
this.refresh(false);
|
|
4656
|
+
this.attachResizeObserverOnce();
|
|
4657
|
+
adapter?.onVisibilityChanged?.(() => this.refreshItem());
|
|
4658
|
+
}
|
|
4659
|
+
/**
|
|
4660
|
+
* Pauses scroll/resize processing to prevent updates during batch operations.
|
|
4661
|
+
* Cancels pending frames and disconnects observers.
|
|
4662
|
+
*/
|
|
4663
|
+
suspend() {
|
|
4664
|
+
this._suspended = true;
|
|
4665
|
+
this.cancelFrames();
|
|
4666
|
+
if (this.scrollEl && this._boundOnScroll) {
|
|
4667
|
+
this.scrollEl.removeEventListener("scroll", this._boundOnScroll);
|
|
4668
|
+
}
|
|
4669
|
+
if (this.resizeObs) {
|
|
4670
|
+
this.resizeObs.disconnect();
|
|
4671
|
+
this._resumeResizeAfter = true;
|
|
4672
|
+
}
|
|
4673
|
+
}
|
|
4674
|
+
/**
|
|
4675
|
+
* Resumes scroll/resize processing after suspension.
|
|
4676
|
+
* Re-attaches listeners and schedules window update.
|
|
4677
|
+
*/
|
|
4678
|
+
resume() {
|
|
4679
|
+
this._suspended = false;
|
|
4680
|
+
if (this.scrollEl && this._boundOnScroll) {
|
|
4681
|
+
this.scrollEl.addEventListener("scroll", this._boundOnScroll, { passive: true });
|
|
4682
|
+
}
|
|
4683
|
+
if (this._resumeResizeAfter) {
|
|
4684
|
+
this.attachResizeObserverOnce();
|
|
4685
|
+
this._resumeResizeAfter = false;
|
|
4686
|
+
}
|
|
4687
|
+
this.scheduleUpdateWindow();
|
|
4688
|
+
}
|
|
4689
|
+
/**
|
|
4690
|
+
* Rebuilds internal state and schedules render update.
|
|
4691
|
+
* Probes initial item height on first run, rebuilds Fenwick tree.
|
|
4692
|
+
*
|
|
4693
|
+
* @param isUpdate - True if called from data update, false on initial setup
|
|
4694
|
+
*/
|
|
4695
|
+
refresh(isUpdate) {
|
|
4696
|
+
if (!this.adapter || !this.viewElement)
|
|
4697
|
+
return;
|
|
4698
|
+
if (!isUpdate)
|
|
4699
|
+
this.refreshItem();
|
|
4700
|
+
const count = this.adapter.itemCount();
|
|
4701
|
+
this._lastRenderCount = count;
|
|
4702
|
+
if (count === 0) {
|
|
4703
|
+
this.resetState();
|
|
4704
|
+
return;
|
|
4705
|
+
}
|
|
4706
|
+
this.heightCache.length = count;
|
|
4707
|
+
if (!this.firstMeasured) {
|
|
4708
|
+
this.probeInitialHeight();
|
|
4709
|
+
this.firstMeasured = true;
|
|
4710
|
+
}
|
|
4711
|
+
this.rebuildFenwick(count);
|
|
4712
|
+
this.scheduleUpdateWindow();
|
|
4713
|
+
}
|
|
4714
|
+
/**
|
|
4715
|
+
* Ensures item at index is rendered and optionally scrolls into view.
|
|
4716
|
+
* Useful for programmatic navigation to specific items.
|
|
4717
|
+
*/
|
|
4718
|
+
ensureRendered(index, opt) {
|
|
4719
|
+
this.mountRange(index, index);
|
|
4720
|
+
if (opt?.scrollIntoView)
|
|
4721
|
+
this.scrollToIndex(index);
|
|
4722
|
+
}
|
|
4723
|
+
/**
|
|
4724
|
+
* Scrolls container to make item at index visible.
|
|
4725
|
+
* Calculates target scroll position accounting for container offset.
|
|
4726
|
+
*/
|
|
4727
|
+
scrollToIndex(index) {
|
|
4728
|
+
const count = this.adapter?.itemCount?.() ?? 0;
|
|
4729
|
+
if (count <= 0)
|
|
4730
|
+
return;
|
|
4731
|
+
const topInContainer = this.offsetTopOf(index);
|
|
4732
|
+
const containerTop = this.containerTopInScroll();
|
|
4733
|
+
const target = containerTop + topInContainer;
|
|
4734
|
+
const maxScroll = Math.max(0, this.scrollEl.scrollHeight - this.scrollEl.clientHeight);
|
|
4735
|
+
this.scrollEl.scrollTop = Math.min(Math.max(0, target), maxScroll);
|
|
4736
|
+
}
|
|
4737
|
+
/**
|
|
4738
|
+
* Cleans up all resources: listeners, observers, DOM elements.
|
|
4739
|
+
* Call before removing component to prevent memory leaks.
|
|
4740
|
+
*/
|
|
4741
|
+
dispose() {
|
|
4742
|
+
this.cancelFrames();
|
|
4743
|
+
if (this.scrollEl && this._boundOnScroll) {
|
|
4744
|
+
this.scrollEl.removeEventListener("scroll", this._boundOnScroll);
|
|
4745
|
+
}
|
|
4746
|
+
this.resizeObs?.disconnect();
|
|
4747
|
+
this.created.forEach(el => el.remove());
|
|
4748
|
+
this.created.clear();
|
|
4749
|
+
}
|
|
4750
|
+
/**
|
|
4751
|
+
* Hard reset after visibility changes (e.g., search/filter cleared).
|
|
4752
|
+
* Rebuilds all height structures and remounts visible window.
|
|
4753
|
+
* Essential for fixing padding calculations after bulk visibility changes.
|
|
4754
|
+
*/
|
|
4755
|
+
refreshItem() {
|
|
4756
|
+
if (!this.adapter)
|
|
4757
|
+
return;
|
|
4758
|
+
const count = this.adapter.itemCount();
|
|
4759
|
+
if (count <= 0)
|
|
4760
|
+
return;
|
|
4761
|
+
this.suspend();
|
|
4762
|
+
this.resetState();
|
|
4763
|
+
this.cleanupInvisibleItems();
|
|
4764
|
+
this.recomputeMeasuredStats(count);
|
|
4765
|
+
this.rebuildFenwick(count);
|
|
4766
|
+
this.start = 0;
|
|
4767
|
+
this.end = -1;
|
|
4768
|
+
this.resume();
|
|
4769
|
+
}
|
|
4770
|
+
/** Cancels all pending animation frames. */
|
|
4771
|
+
cancelFrames() {
|
|
4772
|
+
if (this._rafId != null) {
|
|
4773
|
+
cancelAnimationFrame(this._rafId);
|
|
4774
|
+
this._rafId = null;
|
|
4775
|
+
}
|
|
4776
|
+
if (this._measureRaf != null) {
|
|
4777
|
+
cancelAnimationFrame(this._measureRaf);
|
|
4778
|
+
this._measureRaf = null;
|
|
4779
|
+
}
|
|
4780
|
+
}
|
|
4781
|
+
/** Resets all internal state: DOM, caches, measurements. */
|
|
4782
|
+
resetState() {
|
|
4783
|
+
this.created.forEach(el => el.remove());
|
|
4784
|
+
this.created.clear();
|
|
4785
|
+
this.heightCache = [];
|
|
4786
|
+
this.fenwick.reset(0);
|
|
4787
|
+
this.PadTop.style.height = "0px";
|
|
4788
|
+
this.PadBottom.style.height = "0px";
|
|
4789
|
+
this.firstMeasured = false;
|
|
4790
|
+
this.measuredSum = 0;
|
|
4791
|
+
this.measuredCount = 0;
|
|
4792
|
+
}
|
|
4793
|
+
/**
|
|
4794
|
+
* Measures first item to set initial height estimate.
|
|
4795
|
+
* Removes probe element if dynamic heights disabled.
|
|
4796
|
+
*/
|
|
4797
|
+
probeInitialHeight() {
|
|
4798
|
+
const probe = 0;
|
|
4799
|
+
this.mountIndexOnce(probe);
|
|
4800
|
+
const el = this.created.get(probe);
|
|
4801
|
+
if (!el)
|
|
4802
|
+
return;
|
|
4803
|
+
const h = this.measureOuterHeight(el);
|
|
4804
|
+
if (!isNaN(h))
|
|
4805
|
+
this.opts.estimateItemHeight = h;
|
|
4806
|
+
if (!this.opts.dynamicHeights) {
|
|
4807
|
+
el.remove();
|
|
4808
|
+
this.created.delete(probe);
|
|
4809
|
+
const item = this.adapter.items[probe];
|
|
4810
|
+
if (item) {
|
|
4811
|
+
item.isInit = false;
|
|
4812
|
+
item.view = null;
|
|
4813
|
+
}
|
|
4814
|
+
}
|
|
4815
|
+
}
|
|
4816
|
+
/**
|
|
4817
|
+
* Checks if item is visible (not filtered/hidden).
|
|
4818
|
+
* Defaults to visible if property undefined.
|
|
4819
|
+
*/
|
|
4820
|
+
isIndexVisible(index) {
|
|
4821
|
+
const item = this.adapter?.items?.[index];
|
|
4822
|
+
return item?.visible ?? true;
|
|
4823
|
+
}
|
|
4824
|
+
/**
|
|
4825
|
+
* Finds next visible item index starting from given index.
|
|
4826
|
+
* Returns -1 if no visible items found.
|
|
4827
|
+
*/
|
|
4828
|
+
nextVisibleFrom(index, count) {
|
|
4829
|
+
for (let i = Math.max(0, index); i < count; i++) {
|
|
4830
|
+
if (this.isIndexVisible(i))
|
|
4831
|
+
return i;
|
|
4832
|
+
}
|
|
4833
|
+
return -1;
|
|
4834
|
+
}
|
|
4835
|
+
/**
|
|
4836
|
+
* Recalculates total measured height and count from cache.
|
|
4837
|
+
* Only counts visible items for adaptive estimation.
|
|
4838
|
+
*/
|
|
4839
|
+
recomputeMeasuredStats(count) {
|
|
4840
|
+
this.measuredSum = 0;
|
|
4841
|
+
this.measuredCount = 0;
|
|
4842
|
+
for (let i = 0; i < count; i++) {
|
|
4843
|
+
if (!this.isIndexVisible(i))
|
|
4844
|
+
continue;
|
|
4845
|
+
const h = this.heightCache[i];
|
|
4846
|
+
if (h != null) {
|
|
4847
|
+
this.measuredSum += h;
|
|
4848
|
+
this.measuredCount++;
|
|
4849
|
+
}
|
|
4850
|
+
}
|
|
4851
|
+
}
|
|
4852
|
+
/** Returns view container's top offset relative to scroll container. */
|
|
4853
|
+
containerTopInScroll() {
|
|
4854
|
+
const a = this.viewElement.getBoundingClientRect();
|
|
4855
|
+
const b = this.scrollEl.getBoundingClientRect();
|
|
4856
|
+
return a.top - b.top + this.scrollEl.scrollTop;
|
|
4857
|
+
}
|
|
4858
|
+
/**
|
|
4859
|
+
* Returns sticky header height with 16ms cache to avoid DOM thrashing.
|
|
4860
|
+
* Used to adjust viewport calculations.
|
|
4861
|
+
*/
|
|
4862
|
+
stickyTopHeight() {
|
|
4863
|
+
const now = performance.now();
|
|
4864
|
+
if (now - this._stickyCacheTick < 16)
|
|
4865
|
+
return this._stickyCacheVal;
|
|
4866
|
+
const sticky = this.scrollEl.querySelector(".selective-ui-option-handle:not(.hide)");
|
|
4867
|
+
this._stickyCacheVal = sticky?.offsetHeight ?? 0;
|
|
4868
|
+
this._stickyCacheTick = now;
|
|
4869
|
+
return this._stickyCacheVal;
|
|
4870
|
+
}
|
|
4871
|
+
/** Schedules window update on next frame if not already scheduled. */
|
|
4872
|
+
scheduleUpdateWindow() {
|
|
4873
|
+
if (this._rafId != null || this._suspended)
|
|
4874
|
+
return;
|
|
4875
|
+
this._rafId = requestAnimationFrame(() => {
|
|
4876
|
+
this._rafId = null;
|
|
4877
|
+
this.updateWindowInternal();
|
|
4878
|
+
});
|
|
4879
|
+
}
|
|
4880
|
+
/**
|
|
4881
|
+
* Measures element's total height including margins.
|
|
4882
|
+
* Used for accurate item height tracking.
|
|
4883
|
+
*/
|
|
4884
|
+
measureOuterHeight(el) {
|
|
4885
|
+
const rect = el.getBoundingClientRect();
|
|
4886
|
+
const style = getComputedStyle(el);
|
|
4887
|
+
const mt = parseFloat(style.marginTop) || 0;
|
|
4888
|
+
const mb = parseFloat(style.marginBottom) || 0;
|
|
4889
|
+
return Math.max(1, rect.height + mt + mb);
|
|
4890
|
+
}
|
|
4891
|
+
/**
|
|
4892
|
+
* Returns height estimate for unmeasured items.
|
|
4893
|
+
* Uses adaptive average if enabled, otherwise fixed estimate.
|
|
4894
|
+
*/
|
|
4895
|
+
getEstimate() {
|
|
4896
|
+
if (this.opts.adaptiveEstimate && this.measuredCount > 0) {
|
|
4897
|
+
return Math.max(1, this.measuredSum / this.measuredCount);
|
|
4898
|
+
}
|
|
4899
|
+
return this.opts.estimateItemHeight;
|
|
4900
|
+
}
|
|
4901
|
+
/**
|
|
4902
|
+
* Rebuilds Fenwick tree with current heights and estimates.
|
|
4903
|
+
* Invisible items get 0 height, others use cached or estimated height.
|
|
4904
|
+
*/
|
|
4905
|
+
rebuildFenwick(count) {
|
|
4906
|
+
const est = this.getEstimate();
|
|
4907
|
+
const arr = Array.from({ length: count }, (_, i) => this.isIndexVisible(i) ? (this.heightCache[i] ?? est) : 0);
|
|
4908
|
+
this.fenwick.buildFrom(arr);
|
|
4909
|
+
}
|
|
4910
|
+
/**
|
|
4911
|
+
* Updates cached height at index and applies delta to Fenwick tree.
|
|
4912
|
+
* Updates running average for adaptive estimation.
|
|
4913
|
+
*
|
|
4914
|
+
* @returns True if height changed beyond epsilon threshold
|
|
4915
|
+
*/
|
|
4916
|
+
updateHeightAt(index, newH) {
|
|
4917
|
+
if (!this.isIndexVisible(index))
|
|
4918
|
+
return false;
|
|
4919
|
+
const est = this.getEstimate();
|
|
4920
|
+
const oldH = this.heightCache[index] ?? est;
|
|
4921
|
+
if (Math.abs(newH - oldH) <= VirtualRecyclerView.EPS)
|
|
4922
|
+
return false;
|
|
4923
|
+
const prevMeasured = this.heightCache[index];
|
|
4924
|
+
if (prevMeasured == null) {
|
|
4925
|
+
this.measuredSum += newH;
|
|
4926
|
+
this.measuredCount++;
|
|
4927
|
+
}
|
|
4928
|
+
else {
|
|
4929
|
+
this.measuredSum += newH - prevMeasured;
|
|
4930
|
+
}
|
|
4931
|
+
this.heightCache[index] = newH;
|
|
4932
|
+
this.fenwick.add(index + 1, newH - oldH);
|
|
4933
|
+
return true;
|
|
4934
|
+
}
|
|
4935
|
+
/**
|
|
4936
|
+
* Finds first visible item at or after scroll offset.
|
|
4937
|
+
* Uses Fenwick binary search then adjusts for visibility.
|
|
4938
|
+
*/
|
|
4939
|
+
findFirstVisibleIndex(stRel, count) {
|
|
4940
|
+
const k = this.fenwick.lowerBoundPrefix(Math.max(0, stRel));
|
|
4941
|
+
const raw = Math.min(count - 1, k);
|
|
4942
|
+
const v = this.nextVisibleFrom(raw, count);
|
|
4943
|
+
return v === -1 ? Math.max(0, raw) : v;
|
|
4944
|
+
}
|
|
4945
|
+
/**
|
|
4946
|
+
* Inserts element into DOM maintaining index order.
|
|
4947
|
+
* Tries adjacent siblings first, then scans for insertion point.
|
|
4948
|
+
*/
|
|
4949
|
+
insertIntoHostByIndex(index, el) {
|
|
4950
|
+
el.setAttribute(VirtualRecyclerView.ATTR_INDEX, String(index));
|
|
4951
|
+
const prev = this.created.get(index - 1);
|
|
4952
|
+
if (prev?.parentElement === this.ItemsHost) {
|
|
4953
|
+
prev.after(el);
|
|
4954
|
+
return;
|
|
4955
|
+
}
|
|
4956
|
+
const next = this.created.get(index + 1);
|
|
4957
|
+
if (next?.parentElement === this.ItemsHost) {
|
|
4958
|
+
this.ItemsHost.insertBefore(el, next);
|
|
4959
|
+
return;
|
|
4960
|
+
}
|
|
4961
|
+
const children = Array.from(this.ItemsHost.children);
|
|
4962
|
+
for (const child of children) {
|
|
4963
|
+
const v = child.getAttribute(VirtualRecyclerView.ATTR_INDEX);
|
|
4964
|
+
if (v && Number(v) > index) {
|
|
4965
|
+
this.ItemsHost.insertBefore(el, child);
|
|
4966
|
+
return;
|
|
4967
|
+
}
|
|
4968
|
+
}
|
|
4969
|
+
this.ItemsHost.appendChild(el);
|
|
4970
|
+
}
|
|
4971
|
+
/**
|
|
4972
|
+
* Ensures element is in correct DOM position for its index.
|
|
4973
|
+
* Reinserts if siblings indicate wrong position.
|
|
4974
|
+
*/
|
|
4975
|
+
ensureDomOrder(index, el) {
|
|
4976
|
+
if (el.parentElement !== this.ItemsHost) {
|
|
4977
|
+
this.insertIntoHostByIndex(index, el);
|
|
4978
|
+
return;
|
|
4979
|
+
}
|
|
4980
|
+
el.setAttribute(VirtualRecyclerView.ATTR_INDEX, String(index));
|
|
4981
|
+
const prev = el.previousElementSibling;
|
|
4982
|
+
const next = el.nextElementSibling;
|
|
4983
|
+
const needsReorder = (prev && Number(prev.getAttribute(VirtualRecyclerView.ATTR_INDEX)) > index) ||
|
|
4984
|
+
(next && Number(next.getAttribute(VirtualRecyclerView.ATTR_INDEX)) < index);
|
|
4985
|
+
if (needsReorder) {
|
|
4986
|
+
el.remove();
|
|
4987
|
+
this.insertIntoHostByIndex(index, el);
|
|
4988
|
+
}
|
|
4989
|
+
}
|
|
4990
|
+
/**
|
|
4991
|
+
* Attaches ResizeObserver to measure items when they resize.
|
|
4992
|
+
* Singleton pattern - only creates once per instance.
|
|
4993
|
+
*/
|
|
4994
|
+
attachResizeObserverOnce() {
|
|
4995
|
+
if (this.resizeObs)
|
|
4996
|
+
return;
|
|
4997
|
+
this.resizeObs = new ResizeObserver(() => {
|
|
4998
|
+
if (this._suppressResize || this._suspended || !this.adapter || this._measureRaf != null)
|
|
4999
|
+
return;
|
|
5000
|
+
this._measureRaf = requestAnimationFrame(() => {
|
|
5001
|
+
this._measureRaf = null;
|
|
5002
|
+
this.measureVisibleAndUpdate();
|
|
5003
|
+
});
|
|
5004
|
+
});
|
|
5005
|
+
this.resizeObs.observe(this.ItemsHost);
|
|
5006
|
+
}
|
|
5007
|
+
/**
|
|
5008
|
+
* Measures all currently rendered items and updates height cache.
|
|
5009
|
+
* Triggers window update if any heights changed.
|
|
5010
|
+
*/
|
|
5011
|
+
measureVisibleAndUpdate() {
|
|
5012
|
+
if (!this.adapter)
|
|
5013
|
+
return;
|
|
5014
|
+
const count = this.adapter.itemCount();
|
|
5015
|
+
if (count <= 0)
|
|
5016
|
+
return;
|
|
5017
|
+
let changed = false;
|
|
5018
|
+
for (let i = this.start; i <= this.end; i++) {
|
|
5019
|
+
if (!this.isIndexVisible(i))
|
|
5020
|
+
continue;
|
|
5021
|
+
const item = this.adapter.items[i];
|
|
5022
|
+
const el = item?.view?.getView?.();
|
|
5023
|
+
if (!el)
|
|
5024
|
+
continue;
|
|
5025
|
+
const newH = this.measureOuterHeight(el);
|
|
5026
|
+
if (this.updateHeightAt(i, newH))
|
|
5027
|
+
changed = true;
|
|
5028
|
+
}
|
|
5029
|
+
if (changed) {
|
|
5030
|
+
if (this.opts.adaptiveEstimate)
|
|
5031
|
+
this.rebuildFenwick(count);
|
|
5032
|
+
this.scheduleUpdateWindow();
|
|
5033
|
+
}
|
|
5034
|
+
}
|
|
5035
|
+
/** Scroll event handler - schedules render update. */
|
|
5036
|
+
onScroll() {
|
|
5037
|
+
this.scheduleUpdateWindow();
|
|
5038
|
+
}
|
|
5039
|
+
/**
|
|
5040
|
+
* Core rendering logic - calculates and updates visible window.
|
|
5041
|
+
*
|
|
5042
|
+
* 1. Calculates viewport bounds accounting for scroll and sticky headers
|
|
5043
|
+
* 2. Uses anchor item to prevent scroll jumping during height changes
|
|
5044
|
+
* 3. Determines start/end indices with overscan buffer
|
|
5045
|
+
* 4. Mounts/unmounts items as needed
|
|
5046
|
+
* 5. Measures visible items if dynamic heights enabled
|
|
5047
|
+
* 6. Updates padding elements to maintain total scroll height
|
|
5048
|
+
* 7. Adjusts scroll position to maintain anchor item position
|
|
5049
|
+
*/
|
|
5050
|
+
updateWindowInternal() {
|
|
5051
|
+
if (this._updating || this._suspended)
|
|
5052
|
+
return;
|
|
5053
|
+
this._updating = true;
|
|
5054
|
+
try {
|
|
5055
|
+
if (!this.adapter)
|
|
5056
|
+
return;
|
|
5057
|
+
const count = this.adapter.itemCount();
|
|
5058
|
+
if (count <= 0)
|
|
5059
|
+
return;
|
|
5060
|
+
if (this._lastRenderCount !== count) {
|
|
5061
|
+
this._lastRenderCount = count;
|
|
5062
|
+
this.heightCache.length = count;
|
|
5063
|
+
this.rebuildFenwick(count);
|
|
5064
|
+
}
|
|
5065
|
+
const containerTop = this.containerTopInScroll();
|
|
5066
|
+
const stRel = Math.max(0, this.scrollEl.scrollTop - containerTop);
|
|
5067
|
+
const stickyH = this.stickyTopHeight();
|
|
5068
|
+
const vhEff = Math.max(0, this.scrollEl.clientHeight - stickyH);
|
|
5069
|
+
const anchorIndex = this.findFirstVisibleIndex(stRel, count);
|
|
5070
|
+
const anchorTop = this.offsetTopOf(anchorIndex);
|
|
5071
|
+
const anchorDelta = containerTop + anchorTop - this.scrollEl.scrollTop;
|
|
5072
|
+
const firstVis = this.findFirstVisibleIndex(stRel, count);
|
|
5073
|
+
if (firstVis === -1) {
|
|
5074
|
+
this.resetState();
|
|
5075
|
+
return;
|
|
5076
|
+
}
|
|
5077
|
+
const est = this.getEstimate();
|
|
5078
|
+
const overscanPx = this.opts.overscan * est;
|
|
5079
|
+
let startIndex = this.nextVisibleFrom(Math.min(count - 1, this.fenwick.lowerBoundPrefix(Math.max(0, stRel - overscanPx))), count) ?? firstVis;
|
|
5080
|
+
let endIndex = Math.min(count - 1, this.fenwick.lowerBoundPrefix(stRel + vhEff + overscanPx));
|
|
5081
|
+
if (startIndex === this.start && endIndex === this.end)
|
|
5082
|
+
return;
|
|
5083
|
+
this.start = startIndex;
|
|
5084
|
+
this.end = endIndex;
|
|
5085
|
+
this._suppressResize = true;
|
|
5086
|
+
try {
|
|
5087
|
+
this.mountRange(this.start, this.end);
|
|
5088
|
+
this.unmountOutside(this.start, this.end);
|
|
5089
|
+
if (this.opts.dynamicHeights)
|
|
5090
|
+
this.measureVisibleAndUpdate();
|
|
5091
|
+
const topPx = this.offsetTopOf(this.start);
|
|
5092
|
+
const windowPx = this.windowHeight(this.start, this.end);
|
|
5093
|
+
const total = this.totalHeight(count);
|
|
5094
|
+
const bottomPx = Math.max(0, total - topPx - windowPx);
|
|
5095
|
+
this.PadTop.style.height = `${topPx}px`;
|
|
5096
|
+
this.PadBottom.style.height = `${bottomPx}px`;
|
|
5097
|
+
}
|
|
5098
|
+
finally {
|
|
5099
|
+
this._suppressResize = false;
|
|
5100
|
+
}
|
|
5101
|
+
const anchorTopNew = this.offsetTopOf(anchorIndex);
|
|
5102
|
+
const targetScroll = this.containerTopInScroll() + anchorTopNew - anchorDelta;
|
|
5103
|
+
const maxScroll = Math.max(0, this.scrollEl.scrollHeight - this.scrollEl.clientHeight);
|
|
5104
|
+
const clamped = Math.min(Math.max(0, targetScroll), maxScroll);
|
|
5105
|
+
if (Math.abs(this.scrollEl.scrollTop - clamped) > 0.5) {
|
|
5106
|
+
this.scrollEl.scrollTop = clamped;
|
|
5107
|
+
}
|
|
5108
|
+
}
|
|
5109
|
+
finally {
|
|
5110
|
+
this._updating = false;
|
|
5111
|
+
}
|
|
5112
|
+
}
|
|
5113
|
+
/** Mounts all items in inclusive range [start..end]. */
|
|
5114
|
+
mountRange(start, end) {
|
|
5115
|
+
for (let i = start; i <= end; i++)
|
|
5116
|
+
this.mountIndexOnce(i);
|
|
5117
|
+
}
|
|
5118
|
+
/**
|
|
5119
|
+
* Mounts single item, reusing existing element if available.
|
|
5120
|
+
* Creates view holder on first mount, rebinds on subsequent renders.
|
|
5121
|
+
*/
|
|
5122
|
+
mountIndexOnce(index) {
|
|
5123
|
+
if (!this.isIndexVisible(index)) {
|
|
5124
|
+
const existing = this.created.get(index);
|
|
5125
|
+
if (existing?.parentElement === this.ItemsHost)
|
|
5126
|
+
existing.remove();
|
|
5127
|
+
this.created.delete(index);
|
|
5128
|
+
return;
|
|
5129
|
+
}
|
|
5130
|
+
const item = this.adapter.items[index];
|
|
5131
|
+
const existing = this.created.get(index);
|
|
5132
|
+
if (existing) {
|
|
5133
|
+
if (!item?.view) {
|
|
5134
|
+
existing.remove();
|
|
5135
|
+
this.created.delete(index);
|
|
5136
|
+
}
|
|
5137
|
+
else {
|
|
5138
|
+
this.ensureDomOrder(index, existing);
|
|
5139
|
+
this.adapter.onViewHolder(item, item.view, index);
|
|
5140
|
+
}
|
|
5141
|
+
return;
|
|
5142
|
+
}
|
|
5143
|
+
if (!item.isInit) {
|
|
5144
|
+
const viewer = this.adapter.viewHolder(this.ItemsHost, item);
|
|
5145
|
+
item.view = viewer;
|
|
5146
|
+
this.adapter.onViewHolder(item, viewer, index);
|
|
5147
|
+
item.isInit = true;
|
|
5148
|
+
}
|
|
5149
|
+
else if (item.view) {
|
|
5150
|
+
this.adapter.onViewHolder(item, item.view, index);
|
|
5151
|
+
}
|
|
5152
|
+
const el = item.view?.getView?.();
|
|
5153
|
+
if (el) {
|
|
5154
|
+
this.ensureDomOrder(index, el);
|
|
5155
|
+
this.created.set(index, el);
|
|
5156
|
+
}
|
|
5157
|
+
}
|
|
5158
|
+
/** Removes all mounted items outside [start..end] range. */
|
|
5159
|
+
unmountOutside(start, end) {
|
|
5160
|
+
this.created.forEach((el, idx) => {
|
|
5161
|
+
if (idx < start || idx > end) {
|
|
5162
|
+
if (el.parentElement === this.ItemsHost)
|
|
5163
|
+
el.remove();
|
|
5164
|
+
this.created.delete(idx);
|
|
5165
|
+
}
|
|
5166
|
+
});
|
|
5167
|
+
}
|
|
5168
|
+
/** Removes all items marked as invisible from DOM. */
|
|
5169
|
+
cleanupInvisibleItems() {
|
|
5170
|
+
this.created.forEach((el, idx) => {
|
|
5171
|
+
if (!this.isIndexVisible(idx)) {
|
|
5172
|
+
if (el.parentElement === this.ItemsHost)
|
|
5173
|
+
el.remove();
|
|
5174
|
+
this.created.delete(idx);
|
|
5175
|
+
}
|
|
5176
|
+
});
|
|
5177
|
+
}
|
|
5178
|
+
/** Returns cumulative height from start to top of item at index. */
|
|
5179
|
+
offsetTopOf(index) {
|
|
5180
|
+
return this.fenwick.sum(index);
|
|
5181
|
+
}
|
|
5182
|
+
/** Returns total height of items in range [start..end]. */
|
|
5183
|
+
windowHeight(start, end) {
|
|
5184
|
+
return this.fenwick.rangeSum(start + 1, end + 1);
|
|
5185
|
+
}
|
|
5186
|
+
/** Returns total scrollable height for all items. */
|
|
5187
|
+
totalHeight(count) {
|
|
5188
|
+
return this.fenwick.sum(count);
|
|
5189
|
+
}
|
|
5190
|
+
}
|
|
5191
|
+
VirtualRecyclerView.EPS = 0.5;
|
|
5192
|
+
VirtualRecyclerView.ATTR_INDEX = "data-vindex";
|
|
5193
|
+
|
|
4487
5194
|
/**
|
|
4488
5195
|
* @class
|
|
4489
5196
|
*/
|
|
@@ -4613,7 +5320,12 @@ class SelectBox {
|
|
|
4613
5320
|
select.classList.add("init");
|
|
4614
5321
|
// ModelManager setup
|
|
4615
5322
|
optionModelManager.setupAdapter(MixedAdapter);
|
|
4616
|
-
|
|
5323
|
+
if (options.virtualScroll) {
|
|
5324
|
+
optionModelManager.setupRecyclerView(VirtualRecyclerView);
|
|
5325
|
+
}
|
|
5326
|
+
else {
|
|
5327
|
+
optionModelManager.setupRecyclerView(RecyclerView);
|
|
5328
|
+
}
|
|
4617
5329
|
optionModelManager.createModelResources(Libs.parseSelectToArray(select));
|
|
4618
5330
|
optionModelManager.onUpdated = () => {
|
|
4619
5331
|
container.popup?.triggerResize?.();
|
|
@@ -4686,8 +5398,9 @@ class SelectBox {
|
|
|
4686
5398
|
container.popup?.triggerResize?.();
|
|
4687
5399
|
if (result?.hasResults) {
|
|
4688
5400
|
setTimeout(() => {
|
|
5401
|
+
container.popup?.triggerResize?.();
|
|
4689
5402
|
optionAdapter.resetHighlight();
|
|
4690
|
-
}, options.animationtime
|
|
5403
|
+
}, options.animationtime ? options.animationtime + 10 : 0);
|
|
4691
5404
|
}
|
|
4692
5405
|
})
|
|
4693
5406
|
.catch((error) => {
|
|
@@ -5225,7 +5938,7 @@ class Selective {
|
|
|
5225
5938
|
this.bindedQueries.set(query, merged);
|
|
5226
5939
|
const doneToken = Libs.randomString();
|
|
5227
5940
|
Libs.callbackScheduler.on(doneToken, () => {
|
|
5228
|
-
iEvents.callEvent([this.find(query)], ...merged.on.load);
|
|
5941
|
+
iEvents.callEvent([this.find(query)], ...(merged.on.load));
|
|
5229
5942
|
Libs.callbackScheduler.clear(doneToken);
|
|
5230
5943
|
merged.on.load = [];
|
|
5231
5944
|
});
|
|
@@ -5513,21 +6226,19 @@ class Selective {
|
|
|
5513
6226
|
* to include component styles in the final bundle.
|
|
5514
6227
|
*/
|
|
5515
6228
|
/** Base/global styles for SelectiveUI. */
|
|
5516
|
-
const iVersion = "1.1.5";
|
|
5517
|
-
const iName = "SelectiveUI";
|
|
5518
6229
|
const SECLASS = new Selective();
|
|
5519
6230
|
/**
|
|
5520
6231
|
* Current library version.
|
|
5521
6232
|
*
|
|
5522
6233
|
* Declared as `const` literal type to enable strict typing and easy tree-shaking.
|
|
5523
6234
|
*/
|
|
5524
|
-
const version =
|
|
6235
|
+
const version = "1.2.0";
|
|
5525
6236
|
/**
|
|
5526
6237
|
* Library name identifier.
|
|
5527
6238
|
*
|
|
5528
6239
|
* Can be used for debugging, logging, telemetry, or exposing global namespace metadata.
|
|
5529
6240
|
*/
|
|
5530
|
-
const name =
|
|
6241
|
+
const name = "SelectiveUI";
|
|
5531
6242
|
/**
|
|
5532
6243
|
* Bind SelectiveUI behaviors to elements matched by a CSS selector.
|
|
5533
6244
|
*
|