selective-ui 1.1.6 → 1.2.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/dist/selective-ui.css +7 -1
- package/dist/selective-ui.css.map +1 -1
- package/dist/selective-ui.esm.js +794 -57
- 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 +795 -58
- 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 +29 -18
- 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 +35 -6
- package/src/ts/components/searchbox.ts +2 -0
- package/src/ts/components/selectbox.ts +23 -9
- 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 +763 -0
- package/src/ts/core/model-manager.ts +24 -16
- package/src/ts/core/search-controller.ts +5 -8
- package/src/ts/models/option-model.ts +22 -3
- package/src/ts/services/effector.ts +7 -7
- 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
|
|
1
|
+
/*! Selective UI v1.2.1 | 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;
|
|
@@ -1463,6 +1477,8 @@ class Popup {
|
|
|
1463
1477
|
};
|
|
1464
1478
|
this._resizeObser.connect(this._parent.container.tags.ViewPanel);
|
|
1465
1479
|
callback?.();
|
|
1480
|
+
const rv = this.recyclerView;
|
|
1481
|
+
rv?.resume?.();
|
|
1466
1482
|
},
|
|
1467
1483
|
});
|
|
1468
1484
|
}
|
|
@@ -1473,6 +1489,8 @@ class Popup {
|
|
|
1473
1489
|
close(callback = null) {
|
|
1474
1490
|
if (!this.isCreated || !this.options || !this._resizeObser || !this._effSvc)
|
|
1475
1491
|
return;
|
|
1492
|
+
const rv = this.recyclerView;
|
|
1493
|
+
rv?.suspend?.();
|
|
1476
1494
|
this._resizeObser.disconnect();
|
|
1477
1495
|
this._effSvc.collapse({
|
|
1478
1496
|
duration: this.options.animationtime,
|
|
@@ -1751,6 +1769,7 @@ class SearchBox {
|
|
|
1751
1769
|
isControlKey = true;
|
|
1752
1770
|
this.onEsc?.();
|
|
1753
1771
|
}
|
|
1772
|
+
e.stopPropagation();
|
|
1754
1773
|
});
|
|
1755
1774
|
inputEl.addEventListener("input", () => {
|
|
1756
1775
|
if (isControlKey)
|
|
@@ -2060,8 +2079,7 @@ class EffectorImpl {
|
|
|
2060
2079
|
resize(config) {
|
|
2061
2080
|
if (!this.element)
|
|
2062
2081
|
return this;
|
|
2063
|
-
|
|
2064
|
-
clearTimeout(this._resizeTimeout);
|
|
2082
|
+
this.cancel();
|
|
2065
2083
|
const { duration = 200, width, left, top, maxHeight, realHeight, position = "bottom", animate = true, onComplete, } = config;
|
|
2066
2084
|
const currentPosition = this.element.classList.contains("position-top") ? "top" : "bottom";
|
|
2067
2085
|
const isPositionChanged = currentPosition !== position;
|
|
@@ -2071,7 +2089,7 @@ class EffectorImpl {
|
|
|
2071
2089
|
if (isPositionChanged) {
|
|
2072
2090
|
this.element.style.transition = `top ${duration}ms ease-out, height ${duration}ms ease-out, max-height ${duration}ms ease-out;`;
|
|
2073
2091
|
}
|
|
2074
|
-
|
|
2092
|
+
setTimeout(() => {
|
|
2075
2093
|
const styles = {
|
|
2076
2094
|
width: `${width}px`,
|
|
2077
2095
|
left: `${left}px`,
|
|
@@ -2087,14 +2105,14 @@ class EffectorImpl {
|
|
|
2087
2105
|
else {
|
|
2088
2106
|
this._resizeTimeout = setTimeout(() => {
|
|
2089
2107
|
if (this.element?.style) {
|
|
2090
|
-
this.element.style.transition =
|
|
2108
|
+
this.element.style.transition = null;
|
|
2091
2109
|
}
|
|
2092
2110
|
}, duration);
|
|
2093
2111
|
}
|
|
2094
2112
|
Object.assign(this.element.style, styles);
|
|
2095
2113
|
if (animate && (isPositionChanged || heightDiff > 1)) {
|
|
2096
2114
|
this._resizeTimeout = setTimeout(() => {
|
|
2097
|
-
this.element.style.transition =
|
|
2115
|
+
this.element.style.transition = null;
|
|
2098
2116
|
if (isPositionChanged)
|
|
2099
2117
|
delete this.element.style.transition;
|
|
2100
2118
|
onComplete?.();
|
|
@@ -2105,7 +2123,7 @@ class EffectorImpl {
|
|
|
2105
2123
|
delete this.element.style.transition;
|
|
2106
2124
|
onComplete?.();
|
|
2107
2125
|
}
|
|
2108
|
-
});
|
|
2126
|
+
}, 20);
|
|
2109
2127
|
return this;
|
|
2110
2128
|
}
|
|
2111
2129
|
/**
|
|
@@ -2140,11 +2158,11 @@ class Model {
|
|
|
2140
2158
|
* @param {TView|null} [view=null] - The associated view responsible for rendering the model.
|
|
2141
2159
|
*/
|
|
2142
2160
|
constructor(options, targetElement = null, view = null) {
|
|
2143
|
-
/** @type {TTarget | null} */
|
|
2144
2161
|
this.targetElement = null;
|
|
2145
2162
|
this.view = null;
|
|
2146
2163
|
this.position = -1;
|
|
2147
2164
|
this.isInit = false;
|
|
2165
|
+
this.isRemoved = false;
|
|
2148
2166
|
this.options = options;
|
|
2149
2167
|
this.targetElement = targetElement;
|
|
2150
2168
|
this.view = view;
|
|
@@ -2158,11 +2176,26 @@ class Model {
|
|
|
2158
2176
|
this.targetElement = targetElement;
|
|
2159
2177
|
this.onTargetChanged();
|
|
2160
2178
|
}
|
|
2179
|
+
/**
|
|
2180
|
+
* Cleans up references and invokes the removal hook when the model is no longer needed.
|
|
2181
|
+
*/
|
|
2182
|
+
remove() {
|
|
2183
|
+
this.targetElement = null;
|
|
2184
|
+
this.view?.getView()?.remove?.();
|
|
2185
|
+
this.view = null;
|
|
2186
|
+
this.isRemoved = true;
|
|
2187
|
+
this.onRemove();
|
|
2188
|
+
}
|
|
2161
2189
|
/**
|
|
2162
2190
|
* Hook invoked whenever the target element changes.
|
|
2163
2191
|
* Override in subclasses to react to attribute/content updates (e.g., text, disabled state).
|
|
2164
2192
|
*/
|
|
2165
2193
|
onTargetChanged() { }
|
|
2194
|
+
/**
|
|
2195
|
+
* Hook invoked whenever the target element is removed.
|
|
2196
|
+
* Override in subclasses to react to removal of the element.
|
|
2197
|
+
*/
|
|
2198
|
+
onRemove() { }
|
|
2166
2199
|
}
|
|
2167
2200
|
|
|
2168
2201
|
/**
|
|
@@ -2285,17 +2318,28 @@ class GroupModel extends Model {
|
|
|
2285
2318
|
}
|
|
2286
2319
|
|
|
2287
2320
|
/**
|
|
2288
|
-
* @extends {Model<HTMLOptionElement, OptionViewTags, OptionView>}
|
|
2321
|
+
* @extends {Model<HTMLOptionElement, OptionViewTags, OptionView, SelectiveOptions>}
|
|
2289
2322
|
*/
|
|
2290
2323
|
class OptionModel extends Model {
|
|
2291
|
-
|
|
2292
|
-
|
|
2324
|
+
/**
|
|
2325
|
+
* Constructs a Model instance with configuration options and optional bindings to a target element and view.
|
|
2326
|
+
* Stores references for later updates and rendering.
|
|
2327
|
+
*
|
|
2328
|
+
* @param {SelectiveOptions} options - Configuration options for the model.
|
|
2329
|
+
* @param {HTMLOptionElement|null} [targetElement=null] - The underlying element (e.g., <option> or group node).
|
|
2330
|
+
* @param {OptionView|null} [view=null] - The associated view responsible for rendering the model.
|
|
2331
|
+
*/
|
|
2332
|
+
constructor(options, targetElement = null, view = null) {
|
|
2333
|
+
super(options, targetElement, view);
|
|
2293
2334
|
this._privOnSelected = [];
|
|
2294
2335
|
this._privOnInternalSelected = [];
|
|
2295
2336
|
this._privOnVisibilityChanged = [];
|
|
2296
2337
|
this._visible = true;
|
|
2297
2338
|
this._highlighted = false;
|
|
2298
2339
|
this.group = null;
|
|
2340
|
+
(async () => {
|
|
2341
|
+
this.textToFind = Libs.string2normalize(this.textContent.toLowerCase());
|
|
2342
|
+
})();
|
|
2299
2343
|
}
|
|
2300
2344
|
/**
|
|
2301
2345
|
* Returns the image source from dataset (imgsrc or image), or an empty string if absent.
|
|
@@ -2469,6 +2513,7 @@ class OptionModel extends Model {
|
|
|
2469
2513
|
* and synchronizes initial selected state to the view.
|
|
2470
2514
|
*/
|
|
2471
2515
|
onTargetChanged() {
|
|
2516
|
+
this.textToFind = Libs.string2normalize(this.textContent.toLowerCase());
|
|
2472
2517
|
if (!this.view)
|
|
2473
2518
|
return;
|
|
2474
2519
|
const labelContent = this.view.view.tags.LabelContent;
|
|
@@ -2612,7 +2657,7 @@ class ModelManager {
|
|
|
2612
2657
|
// Adapter expects TModel[], but this manager's list is GroupModel|OptionModel.
|
|
2613
2658
|
this._privAdapterHandle.syncFromSource(this._privModelList);
|
|
2614
2659
|
}
|
|
2615
|
-
this.refresh();
|
|
2660
|
+
this.refresh(false);
|
|
2616
2661
|
}
|
|
2617
2662
|
/**
|
|
2618
2663
|
* Requests a view refresh if an adapter has been initialized,
|
|
@@ -2621,7 +2666,7 @@ class ModelManager {
|
|
|
2621
2666
|
notify() {
|
|
2622
2667
|
if (!this._privAdapterHandle)
|
|
2623
2668
|
return;
|
|
2624
|
-
this.refresh();
|
|
2669
|
+
this.refresh(false);
|
|
2625
2670
|
}
|
|
2626
2671
|
/**
|
|
2627
2672
|
* Initializes adapter and recycler view instances, attaches them to a container element,
|
|
@@ -2631,8 +2676,8 @@ class ModelManager {
|
|
|
2631
2676
|
this._privAdapterHandle = new this._privAdapter(this._privModelList);
|
|
2632
2677
|
Object.assign(this._privAdapterHandle, adapterOpt);
|
|
2633
2678
|
this._privRecyclerViewHandle = new this._privRecyclerView(viewElement);
|
|
2634
|
-
this._privRecyclerViewHandle.setAdapter(this._privAdapterHandle);
|
|
2635
2679
|
Object.assign(this._privRecyclerViewHandle, recyclerViewOpt);
|
|
2680
|
+
this._privRecyclerViewHandle.setAdapter(this._privAdapterHandle);
|
|
2636
2681
|
}
|
|
2637
2682
|
/**
|
|
2638
2683
|
* Diffs existing models against new <optgroup>/<option> data to update in place:
|
|
@@ -2713,18 +2758,21 @@ class ModelManager {
|
|
|
2713
2758
|
position++;
|
|
2714
2759
|
}
|
|
2715
2760
|
});
|
|
2761
|
+
let isUpdate = true;
|
|
2716
2762
|
oldGroupMap.forEach((removedGroup) => {
|
|
2717
|
-
|
|
2763
|
+
isUpdate = false;
|
|
2764
|
+
removedGroup.remove();
|
|
2718
2765
|
});
|
|
2719
2766
|
oldOptionMap.forEach((removedOption) => {
|
|
2720
|
-
|
|
2767
|
+
isUpdate = false;
|
|
2768
|
+
removedOption.remove();
|
|
2721
2769
|
});
|
|
2722
2770
|
this._privModelList = newModels;
|
|
2723
2771
|
if (this._privAdapterHandle) {
|
|
2724
2772
|
this._privAdapterHandle.updateData(this._privModelList);
|
|
2725
2773
|
}
|
|
2726
2774
|
this.onUpdated();
|
|
2727
|
-
this.refresh();
|
|
2775
|
+
this.refresh(isUpdate);
|
|
2728
2776
|
}
|
|
2729
2777
|
/**
|
|
2730
2778
|
* Hook invoked after the manager completes an update or refresh cycle.
|
|
@@ -2743,11 +2791,13 @@ class ModelManager {
|
|
|
2743
2791
|
/**
|
|
2744
2792
|
* Re-renders the recycler view if present and invokes the post-refresh hook.
|
|
2745
2793
|
* No-op if the recycler view is not initialized.
|
|
2794
|
+
*
|
|
2795
|
+
* @param isUpdate - Indicates if this refresh is due to an update operation.
|
|
2746
2796
|
*/
|
|
2747
|
-
refresh() {
|
|
2797
|
+
refresh(isUpdate) {
|
|
2748
2798
|
if (!this._privRecyclerViewHandle)
|
|
2749
2799
|
return;
|
|
2750
|
-
this._privRecyclerViewHandle.refresh();
|
|
2800
|
+
this._privRecyclerViewHandle.refresh(isUpdate);
|
|
2751
2801
|
this.onUpdated();
|
|
2752
2802
|
}
|
|
2753
2803
|
/**
|
|
@@ -2840,8 +2890,10 @@ class RecyclerView {
|
|
|
2840
2890
|
/**
|
|
2841
2891
|
* Forces a re-render of the current adapter state into the container.
|
|
2842
2892
|
* Useful when visual updates are required without changing the data.
|
|
2893
|
+
*
|
|
2894
|
+
* @param isUpdate - Indicates if this refresh is due to an update operation.
|
|
2843
2895
|
*/
|
|
2844
|
-
refresh() {
|
|
2896
|
+
refresh(isUpdate) {
|
|
2845
2897
|
this.render();
|
|
2846
2898
|
}
|
|
2847
2899
|
}
|
|
@@ -3159,9 +3211,7 @@ class SearchController {
|
|
|
3159
3211
|
}
|
|
3160
3212
|
let hasVisibleItems = false;
|
|
3161
3213
|
flatOptions.forEach((opt) => {
|
|
3162
|
-
const
|
|
3163
|
-
const textNA = Libs.string2normalize(text);
|
|
3164
|
-
const isVisible = lower === "" || text.includes(lower) || textNA.includes(lowerNA);
|
|
3214
|
+
const isVisible = lower === "" || opt.textToFind.includes(lowerNA);
|
|
3165
3215
|
opt.visible = isVisible;
|
|
3166
3216
|
if (isVisible)
|
|
3167
3217
|
hasVisibleItems = true;
|
|
@@ -3585,11 +3635,11 @@ class Adapter {
|
|
|
3585
3635
|
*/
|
|
3586
3636
|
onViewHolder(item, viewer, position) {
|
|
3587
3637
|
const v = viewer;
|
|
3588
|
-
if (
|
|
3589
|
-
v?.
|
|
3638
|
+
if (item.isInit) {
|
|
3639
|
+
v?.update?.();
|
|
3590
3640
|
}
|
|
3591
3641
|
else {
|
|
3592
|
-
v?.
|
|
3642
|
+
v?.render?.();
|
|
3593
3643
|
}
|
|
3594
3644
|
}
|
|
3595
3645
|
/**
|
|
@@ -4131,6 +4181,19 @@ class MixedAdapter extends Adapter {
|
|
|
4131
4181
|
this.groups = [];
|
|
4132
4182
|
this.flatOptions = [];
|
|
4133
4183
|
this._buildFlatStructure();
|
|
4184
|
+
Libs.callbackScheduler.on(`sche_vis_${this.adapterKey}`, () => {
|
|
4185
|
+
const visibleCount = this.flatOptions.filter((item) => item.visible).length;
|
|
4186
|
+
const totalCount = this.flatOptions.length;
|
|
4187
|
+
this._visibilityChangedCallbacks.forEach((callback) => {
|
|
4188
|
+
callback({
|
|
4189
|
+
visibleCount,
|
|
4190
|
+
totalCount,
|
|
4191
|
+
hasVisible: visibleCount > 0,
|
|
4192
|
+
isEmpty: totalCount === 0,
|
|
4193
|
+
});
|
|
4194
|
+
});
|
|
4195
|
+
Libs.callbackScheduler.run(`sche_vis_proxy_${this.adapterKey}`);
|
|
4196
|
+
}, { debounce: 10 });
|
|
4134
4197
|
}
|
|
4135
4198
|
/**
|
|
4136
4199
|
* Build flat list of all options for navigation
|
|
@@ -4366,16 +4429,7 @@ class MixedAdapter extends Adapter {
|
|
|
4366
4429
|
* Computes visible and total counts, then emits aggregated state.
|
|
4367
4430
|
*/
|
|
4368
4431
|
_notifyVisibilityChanged() {
|
|
4369
|
-
|
|
4370
|
-
const totalCount = this.flatOptions.length;
|
|
4371
|
-
this._visibilityChangedCallbacks.forEach((callback) => {
|
|
4372
|
-
callback({
|
|
4373
|
-
visibleCount,
|
|
4374
|
-
totalCount,
|
|
4375
|
-
hasVisible: visibleCount > 0,
|
|
4376
|
-
isEmpty: totalCount === 0,
|
|
4377
|
-
});
|
|
4378
|
-
});
|
|
4432
|
+
Libs.callbackScheduler.run(`sche_vis_${this.adapterKey}`);
|
|
4379
4433
|
}
|
|
4380
4434
|
/**
|
|
4381
4435
|
* Computes and returns current visibility statistics for options.
|
|
@@ -4456,14 +4510,18 @@ class MixedAdapter extends Adapter {
|
|
|
4456
4510
|
}
|
|
4457
4511
|
for (let i = index; i < this.flatOptions.length; i++) {
|
|
4458
4512
|
const item = this.flatOptions[i];
|
|
4459
|
-
if (!item
|
|
4513
|
+
if (!item?.visible)
|
|
4460
4514
|
continue;
|
|
4461
4515
|
item.highlighted = true;
|
|
4462
4516
|
this._currentHighlightIndex = i;
|
|
4463
4517
|
if (isScrollToView) {
|
|
4464
4518
|
const el = item.view?.getView?.();
|
|
4465
|
-
if (el)
|
|
4466
|
-
el.scrollIntoView({ block:
|
|
4519
|
+
if (el) {
|
|
4520
|
+
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
4521
|
+
}
|
|
4522
|
+
else {
|
|
4523
|
+
this.recyclerView?.ensureRendered?.(i, { scrollIntoView: true });
|
|
4524
|
+
}
|
|
4467
4525
|
}
|
|
4468
4526
|
this.onHighlightChange(i, item.view?.getView?.()?.id);
|
|
4469
4527
|
return;
|
|
@@ -4481,6 +4539,673 @@ class MixedAdapter extends Adapter {
|
|
|
4481
4539
|
onCollapsedChange(model, collapsed) { }
|
|
4482
4540
|
}
|
|
4483
4541
|
|
|
4542
|
+
/**
|
|
4543
|
+
* Fenwick tree (Binary Indexed Tree) for efficient prefix sum queries.
|
|
4544
|
+
* Supports O(log n) update and query operations for cumulative item heights.
|
|
4545
|
+
* Uses 1-based indexing internally for BIT operations.
|
|
4546
|
+
*/
|
|
4547
|
+
class Fenwick {
|
|
4548
|
+
constructor(n = 0) {
|
|
4549
|
+
this.bit = [];
|
|
4550
|
+
this.n = 0;
|
|
4551
|
+
this.reset(n);
|
|
4552
|
+
}
|
|
4553
|
+
/** Resets tree to new size, clearing all values. */
|
|
4554
|
+
reset(n) {
|
|
4555
|
+
this.n = n;
|
|
4556
|
+
this.bit = new Array(n + 1).fill(0);
|
|
4557
|
+
}
|
|
4558
|
+
/** Adds delta to element at 1-based index i. */
|
|
4559
|
+
add(i, delta) {
|
|
4560
|
+
for (let x = i; x <= this.n; x += x & -x)
|
|
4561
|
+
this.bit[x] += delta;
|
|
4562
|
+
}
|
|
4563
|
+
/** Returns prefix sum for range [1..i]. */
|
|
4564
|
+
sum(i) {
|
|
4565
|
+
let s = 0;
|
|
4566
|
+
for (let x = i; x > 0; x -= x & -x)
|
|
4567
|
+
s += this.bit[x];
|
|
4568
|
+
return s;
|
|
4569
|
+
}
|
|
4570
|
+
/** Returns sum in range [l..r] (1-based, inclusive). */
|
|
4571
|
+
rangeSum(l, r) {
|
|
4572
|
+
return r < l ? 0 : this.sum(r) - this.sum(l - 1);
|
|
4573
|
+
}
|
|
4574
|
+
/** Builds tree from 0-based array in O(n log n). */
|
|
4575
|
+
buildFrom(arr) {
|
|
4576
|
+
this.reset(arr.length);
|
|
4577
|
+
arr.forEach((val, i) => this.add(i + 1, val));
|
|
4578
|
+
}
|
|
4579
|
+
/**
|
|
4580
|
+
* Binary search to find largest index where prefix sum <= target.
|
|
4581
|
+
* Returns count of items that fit within target height.
|
|
4582
|
+
*/
|
|
4583
|
+
lowerBoundPrefix(target) {
|
|
4584
|
+
let idx = 0, bitMask = 1;
|
|
4585
|
+
while (bitMask << 1 <= this.n)
|
|
4586
|
+
bitMask <<= 1;
|
|
4587
|
+
let cur = 0;
|
|
4588
|
+
for (let step = bitMask; step !== 0; step >>= 1) {
|
|
4589
|
+
const next = idx + step;
|
|
4590
|
+
if (next <= this.n && cur + this.bit[next] <= target) {
|
|
4591
|
+
idx = next;
|
|
4592
|
+
cur += this.bit[next];
|
|
4593
|
+
}
|
|
4594
|
+
}
|
|
4595
|
+
return idx;
|
|
4596
|
+
}
|
|
4597
|
+
}
|
|
4598
|
+
/**
|
|
4599
|
+
* Virtual RecyclerView with efficient windowing and dynamic height support.
|
|
4600
|
+
*
|
|
4601
|
+
* Only renders items visible in viewport plus overscan buffer, using padding
|
|
4602
|
+
* elements to simulate scroll height. Supports variable item heights with
|
|
4603
|
+
* adaptive estimation and maintains scroll position during height changes.
|
|
4604
|
+
*
|
|
4605
|
+
* @template TItem - Model type for list items
|
|
4606
|
+
* @template TAdapter - Adapter managing item views
|
|
4607
|
+
*/
|
|
4608
|
+
class VirtualRecyclerView extends RecyclerView {
|
|
4609
|
+
/** Creates virtual recycler view with optional root element. */
|
|
4610
|
+
constructor(viewElement = null) {
|
|
4611
|
+
super(viewElement);
|
|
4612
|
+
this.opts = {
|
|
4613
|
+
scrollEl: undefined,
|
|
4614
|
+
estimateItemHeight: 36,
|
|
4615
|
+
overscan: 8,
|
|
4616
|
+
dynamicHeights: true,
|
|
4617
|
+
adaptiveEstimate: true,
|
|
4618
|
+
};
|
|
4619
|
+
this.heightCache = [];
|
|
4620
|
+
this.fenwick = new Fenwick(0);
|
|
4621
|
+
this.created = new Map();
|
|
4622
|
+
this.firstMeasured = false;
|
|
4623
|
+
this.start = 0;
|
|
4624
|
+
this.end = -1;
|
|
4625
|
+
this._rafId = null;
|
|
4626
|
+
this._measureRaf = null;
|
|
4627
|
+
this._updating = false;
|
|
4628
|
+
this._suppressResize = false;
|
|
4629
|
+
this._lastRenderCount = 0;
|
|
4630
|
+
this._suspended = false;
|
|
4631
|
+
this._resumeResizeAfter = false;
|
|
4632
|
+
this._stickyCacheTick = 0;
|
|
4633
|
+
this._stickyCacheVal = 0;
|
|
4634
|
+
this.measuredSum = 0;
|
|
4635
|
+
this.measuredCount = 0;
|
|
4636
|
+
}
|
|
4637
|
+
/** Updates virtualization settings (overscan, heights, etc). */
|
|
4638
|
+
configure(opts) {
|
|
4639
|
+
this.opts = { ...this.opts, ...opts };
|
|
4640
|
+
}
|
|
4641
|
+
/**
|
|
4642
|
+
* Binds adapter and initializes virtualization scaffold.
|
|
4643
|
+
* Removes previous adapter if exists, sets up scroll listeners and DOM structure.
|
|
4644
|
+
*/
|
|
4645
|
+
setAdapter(adapter) {
|
|
4646
|
+
if (this.adapter)
|
|
4647
|
+
this.dispose();
|
|
4648
|
+
super.setAdapter(adapter);
|
|
4649
|
+
adapter.recyclerView = this;
|
|
4650
|
+
if (!this.viewElement)
|
|
4651
|
+
return;
|
|
4652
|
+
this.viewElement.replaceChildren();
|
|
4653
|
+
const nodeMounted = Libs.mountNode({
|
|
4654
|
+
PadTop: { tag: { node: "div", classList: "selective-ui-virtual-pad-top" } },
|
|
4655
|
+
ItemsHost: { tag: { node: "div", classList: "selective-ui-virtual-items" } },
|
|
4656
|
+
PadBottom: { tag: { node: "div", classList: "selective-ui-virtual-pad-bottom" } },
|
|
4657
|
+
}, this.viewElement);
|
|
4658
|
+
this.PadTop = nodeMounted.PadTop;
|
|
4659
|
+
this.ItemsHost = nodeMounted.ItemsHost;
|
|
4660
|
+
this.PadBottom = nodeMounted.PadBottom;
|
|
4661
|
+
this.scrollEl = this.opts.scrollEl
|
|
4662
|
+
?? this.viewElement.closest(".selective-ui-popup")
|
|
4663
|
+
?? this.viewElement.parentElement;
|
|
4664
|
+
if (!this.scrollEl)
|
|
4665
|
+
throw new Error("VirtualRecyclerView: scrollEl not found");
|
|
4666
|
+
this._boundOnScroll = this.onScroll.bind(this);
|
|
4667
|
+
this.scrollEl.addEventListener("scroll", this._boundOnScroll, { passive: true });
|
|
4668
|
+
this.refresh(false);
|
|
4669
|
+
this.attachResizeObserverOnce();
|
|
4670
|
+
adapter?.onVisibilityChanged?.(() => this.refreshItem());
|
|
4671
|
+
}
|
|
4672
|
+
/**
|
|
4673
|
+
* Pauses scroll/resize processing to prevent updates during batch operations.
|
|
4674
|
+
* Cancels pending frames and disconnects observers.
|
|
4675
|
+
*/
|
|
4676
|
+
suspend() {
|
|
4677
|
+
this._suspended = true;
|
|
4678
|
+
this.cancelFrames();
|
|
4679
|
+
if (this.scrollEl && this._boundOnScroll) {
|
|
4680
|
+
this.scrollEl.removeEventListener("scroll", this._boundOnScroll);
|
|
4681
|
+
}
|
|
4682
|
+
if (this.resizeObs) {
|
|
4683
|
+
this.resizeObs.disconnect();
|
|
4684
|
+
this._resumeResizeAfter = true;
|
|
4685
|
+
}
|
|
4686
|
+
}
|
|
4687
|
+
/**
|
|
4688
|
+
* Resumes scroll/resize processing after suspension.
|
|
4689
|
+
* Re-attaches listeners and schedules window update.
|
|
4690
|
+
*/
|
|
4691
|
+
resume() {
|
|
4692
|
+
this._suspended = false;
|
|
4693
|
+
if (this.scrollEl && this._boundOnScroll) {
|
|
4694
|
+
this.scrollEl.addEventListener("scroll", this._boundOnScroll, { passive: true });
|
|
4695
|
+
}
|
|
4696
|
+
if (this._resumeResizeAfter) {
|
|
4697
|
+
this.attachResizeObserverOnce();
|
|
4698
|
+
this._resumeResizeAfter = false;
|
|
4699
|
+
}
|
|
4700
|
+
this.scheduleUpdateWindow();
|
|
4701
|
+
}
|
|
4702
|
+
/**
|
|
4703
|
+
* Rebuilds internal state and schedules render update.
|
|
4704
|
+
* Probes initial item height on first run, rebuilds Fenwick tree.
|
|
4705
|
+
*
|
|
4706
|
+
* @param isUpdate - True if called from data update, false on initial setup
|
|
4707
|
+
*/
|
|
4708
|
+
refresh(isUpdate) {
|
|
4709
|
+
if (!this.adapter || !this.viewElement)
|
|
4710
|
+
return;
|
|
4711
|
+
if (!isUpdate)
|
|
4712
|
+
this.refreshItem();
|
|
4713
|
+
const count = this.adapter.itemCount();
|
|
4714
|
+
this._lastRenderCount = count;
|
|
4715
|
+
if (count === 0) {
|
|
4716
|
+
this.resetState();
|
|
4717
|
+
return;
|
|
4718
|
+
}
|
|
4719
|
+
this.heightCache.length = count;
|
|
4720
|
+
if (!this.firstMeasured) {
|
|
4721
|
+
this.probeInitialHeight();
|
|
4722
|
+
this.firstMeasured = true;
|
|
4723
|
+
}
|
|
4724
|
+
this.rebuildFenwick(count);
|
|
4725
|
+
this.scheduleUpdateWindow();
|
|
4726
|
+
}
|
|
4727
|
+
/**
|
|
4728
|
+
* Ensures item at index is rendered and optionally scrolls into view.
|
|
4729
|
+
* Useful for programmatic navigation to specific items.
|
|
4730
|
+
*/
|
|
4731
|
+
ensureRendered(index, opt) {
|
|
4732
|
+
this.mountRange(index, index);
|
|
4733
|
+
if (opt?.scrollIntoView)
|
|
4734
|
+
this.scrollToIndex(index);
|
|
4735
|
+
}
|
|
4736
|
+
/**
|
|
4737
|
+
* Scrolls container to make item at index visible.
|
|
4738
|
+
* Calculates target scroll position accounting for container offset.
|
|
4739
|
+
*/
|
|
4740
|
+
scrollToIndex(index) {
|
|
4741
|
+
const count = this.adapter?.itemCount?.() ?? 0;
|
|
4742
|
+
if (count <= 0)
|
|
4743
|
+
return;
|
|
4744
|
+
const topInContainer = this.offsetTopOf(index);
|
|
4745
|
+
const containerTop = this.containerTopInScroll();
|
|
4746
|
+
const target = containerTop + topInContainer;
|
|
4747
|
+
const maxScroll = Math.max(0, this.scrollEl.scrollHeight - this.scrollEl.clientHeight);
|
|
4748
|
+
this.scrollEl.scrollTop = Math.min(Math.max(0, target), maxScroll);
|
|
4749
|
+
}
|
|
4750
|
+
/**
|
|
4751
|
+
* Cleans up all resources: listeners, observers, DOM elements.
|
|
4752
|
+
* Call before removing component to prevent memory leaks.
|
|
4753
|
+
*/
|
|
4754
|
+
dispose() {
|
|
4755
|
+
this.cancelFrames();
|
|
4756
|
+
if (this.scrollEl && this._boundOnScroll) {
|
|
4757
|
+
this.scrollEl.removeEventListener("scroll", this._boundOnScroll);
|
|
4758
|
+
}
|
|
4759
|
+
this.resizeObs?.disconnect();
|
|
4760
|
+
this.created.forEach(el => el.remove());
|
|
4761
|
+
this.created.clear();
|
|
4762
|
+
}
|
|
4763
|
+
/**
|
|
4764
|
+
* Hard reset after visibility changes (e.g., search/filter cleared).
|
|
4765
|
+
* Rebuilds all height structures and remounts visible window.
|
|
4766
|
+
* Essential for fixing padding calculations after bulk visibility changes.
|
|
4767
|
+
*/
|
|
4768
|
+
refreshItem() {
|
|
4769
|
+
if (!this.adapter)
|
|
4770
|
+
return;
|
|
4771
|
+
const count = this.adapter.itemCount();
|
|
4772
|
+
if (count <= 0)
|
|
4773
|
+
return;
|
|
4774
|
+
this.suspend();
|
|
4775
|
+
this.resetState();
|
|
4776
|
+
this.cleanupInvisibleItems();
|
|
4777
|
+
this.recomputeMeasuredStats(count);
|
|
4778
|
+
this.rebuildFenwick(count);
|
|
4779
|
+
this.start = 0;
|
|
4780
|
+
this.end = -1;
|
|
4781
|
+
this.resume();
|
|
4782
|
+
}
|
|
4783
|
+
/** Cancels all pending animation frames. */
|
|
4784
|
+
cancelFrames() {
|
|
4785
|
+
if (this._rafId != null) {
|
|
4786
|
+
cancelAnimationFrame(this._rafId);
|
|
4787
|
+
this._rafId = null;
|
|
4788
|
+
}
|
|
4789
|
+
if (this._measureRaf != null) {
|
|
4790
|
+
cancelAnimationFrame(this._measureRaf);
|
|
4791
|
+
this._measureRaf = null;
|
|
4792
|
+
}
|
|
4793
|
+
}
|
|
4794
|
+
/** Resets all internal state: DOM, caches, measurements. */
|
|
4795
|
+
resetState() {
|
|
4796
|
+
this.created.forEach(el => el.remove());
|
|
4797
|
+
this.created.clear();
|
|
4798
|
+
this.heightCache = [];
|
|
4799
|
+
this.fenwick.reset(0);
|
|
4800
|
+
this.PadTop.style.height = "0px";
|
|
4801
|
+
this.PadBottom.style.height = "0px";
|
|
4802
|
+
this.firstMeasured = false;
|
|
4803
|
+
this.measuredSum = 0;
|
|
4804
|
+
this.measuredCount = 0;
|
|
4805
|
+
}
|
|
4806
|
+
/**
|
|
4807
|
+
* Measures first item to set initial height estimate.
|
|
4808
|
+
* Removes probe element if dynamic heights disabled.
|
|
4809
|
+
*/
|
|
4810
|
+
probeInitialHeight() {
|
|
4811
|
+
const probe = 0;
|
|
4812
|
+
this.mountIndexOnce(probe);
|
|
4813
|
+
const el = this.created.get(probe);
|
|
4814
|
+
if (!el)
|
|
4815
|
+
return;
|
|
4816
|
+
const h = this.measureOuterHeight(el);
|
|
4817
|
+
if (!isNaN(h))
|
|
4818
|
+
this.opts.estimateItemHeight = h;
|
|
4819
|
+
if (!this.opts.dynamicHeights) {
|
|
4820
|
+
el.remove();
|
|
4821
|
+
this.created.delete(probe);
|
|
4822
|
+
const item = this.adapter.items[probe];
|
|
4823
|
+
if (item) {
|
|
4824
|
+
item.isInit = false;
|
|
4825
|
+
item.view = null;
|
|
4826
|
+
}
|
|
4827
|
+
}
|
|
4828
|
+
}
|
|
4829
|
+
/**
|
|
4830
|
+
* Checks if item is visible (not filtered/hidden).
|
|
4831
|
+
* Defaults to visible if property undefined.
|
|
4832
|
+
*/
|
|
4833
|
+
isIndexVisible(index) {
|
|
4834
|
+
const item = this.adapter?.items?.[index];
|
|
4835
|
+
return item?.visible ?? true;
|
|
4836
|
+
}
|
|
4837
|
+
/**
|
|
4838
|
+
* Finds next visible item index starting from given index.
|
|
4839
|
+
* Returns -1 if no visible items found.
|
|
4840
|
+
*/
|
|
4841
|
+
nextVisibleFrom(index, count) {
|
|
4842
|
+
for (let i = Math.max(0, index); i < count; i++) {
|
|
4843
|
+
if (this.isIndexVisible(i))
|
|
4844
|
+
return i;
|
|
4845
|
+
}
|
|
4846
|
+
return -1;
|
|
4847
|
+
}
|
|
4848
|
+
/**
|
|
4849
|
+
* Recalculates total measured height and count from cache.
|
|
4850
|
+
* Only counts visible items for adaptive estimation.
|
|
4851
|
+
*/
|
|
4852
|
+
recomputeMeasuredStats(count) {
|
|
4853
|
+
this.measuredSum = 0;
|
|
4854
|
+
this.measuredCount = 0;
|
|
4855
|
+
for (let i = 0; i < count; i++) {
|
|
4856
|
+
if (!this.isIndexVisible(i))
|
|
4857
|
+
continue;
|
|
4858
|
+
const h = this.heightCache[i];
|
|
4859
|
+
if (h != null) {
|
|
4860
|
+
this.measuredSum += h;
|
|
4861
|
+
this.measuredCount++;
|
|
4862
|
+
}
|
|
4863
|
+
}
|
|
4864
|
+
}
|
|
4865
|
+
/** Returns view container's top offset relative to scroll container. */
|
|
4866
|
+
containerTopInScroll() {
|
|
4867
|
+
const a = this.viewElement.getBoundingClientRect();
|
|
4868
|
+
const b = this.scrollEl.getBoundingClientRect();
|
|
4869
|
+
return a.top - b.top + this.scrollEl.scrollTop;
|
|
4870
|
+
}
|
|
4871
|
+
/**
|
|
4872
|
+
* Returns sticky header height with 16ms cache to avoid DOM thrashing.
|
|
4873
|
+
* Used to adjust viewport calculations.
|
|
4874
|
+
*/
|
|
4875
|
+
stickyTopHeight() {
|
|
4876
|
+
const now = performance.now();
|
|
4877
|
+
if (now - this._stickyCacheTick < 16)
|
|
4878
|
+
return this._stickyCacheVal;
|
|
4879
|
+
const sticky = this.scrollEl.querySelector(".selective-ui-option-handle:not(.hide)");
|
|
4880
|
+
this._stickyCacheVal = sticky?.offsetHeight ?? 0;
|
|
4881
|
+
this._stickyCacheTick = now;
|
|
4882
|
+
return this._stickyCacheVal;
|
|
4883
|
+
}
|
|
4884
|
+
/** Schedules window update on next frame if not already scheduled. */
|
|
4885
|
+
scheduleUpdateWindow() {
|
|
4886
|
+
if (this._rafId != null || this._suspended)
|
|
4887
|
+
return;
|
|
4888
|
+
this._rafId = requestAnimationFrame(() => {
|
|
4889
|
+
this._rafId = null;
|
|
4890
|
+
this.updateWindowInternal();
|
|
4891
|
+
});
|
|
4892
|
+
}
|
|
4893
|
+
/**
|
|
4894
|
+
* Measures element's total height including margins.
|
|
4895
|
+
* Used for accurate item height tracking.
|
|
4896
|
+
*/
|
|
4897
|
+
measureOuterHeight(el) {
|
|
4898
|
+
const rect = el.getBoundingClientRect();
|
|
4899
|
+
const style = getComputedStyle(el);
|
|
4900
|
+
const mt = parseFloat(style.marginTop) || 0;
|
|
4901
|
+
const mb = parseFloat(style.marginBottom) || 0;
|
|
4902
|
+
return Math.max(1, rect.height + mt + mb);
|
|
4903
|
+
}
|
|
4904
|
+
/**
|
|
4905
|
+
* Returns height estimate for unmeasured items.
|
|
4906
|
+
* Uses adaptive average if enabled, otherwise fixed estimate.
|
|
4907
|
+
*/
|
|
4908
|
+
getEstimate() {
|
|
4909
|
+
if (this.opts.adaptiveEstimate && this.measuredCount > 0) {
|
|
4910
|
+
return Math.max(1, this.measuredSum / this.measuredCount);
|
|
4911
|
+
}
|
|
4912
|
+
return this.opts.estimateItemHeight;
|
|
4913
|
+
}
|
|
4914
|
+
/**
|
|
4915
|
+
* Rebuilds Fenwick tree with current heights and estimates.
|
|
4916
|
+
* Invisible items get 0 height, others use cached or estimated height.
|
|
4917
|
+
*/
|
|
4918
|
+
rebuildFenwick(count) {
|
|
4919
|
+
const est = this.getEstimate();
|
|
4920
|
+
const arr = Array.from({ length: count }, (_, i) => this.isIndexVisible(i) ? (this.heightCache[i] ?? est) : 0);
|
|
4921
|
+
this.fenwick.buildFrom(arr);
|
|
4922
|
+
}
|
|
4923
|
+
/**
|
|
4924
|
+
* Updates cached height at index and applies delta to Fenwick tree.
|
|
4925
|
+
* Updates running average for adaptive estimation.
|
|
4926
|
+
*
|
|
4927
|
+
* @returns True if height changed beyond epsilon threshold
|
|
4928
|
+
*/
|
|
4929
|
+
updateHeightAt(index, newH) {
|
|
4930
|
+
if (!this.isIndexVisible(index))
|
|
4931
|
+
return false;
|
|
4932
|
+
const est = this.getEstimate();
|
|
4933
|
+
const oldH = this.heightCache[index] ?? est;
|
|
4934
|
+
if (Math.abs(newH - oldH) <= VirtualRecyclerView.EPS)
|
|
4935
|
+
return false;
|
|
4936
|
+
const prevMeasured = this.heightCache[index];
|
|
4937
|
+
if (prevMeasured == null) {
|
|
4938
|
+
this.measuredSum += newH;
|
|
4939
|
+
this.measuredCount++;
|
|
4940
|
+
}
|
|
4941
|
+
else {
|
|
4942
|
+
this.measuredSum += newH - prevMeasured;
|
|
4943
|
+
}
|
|
4944
|
+
this.heightCache[index] = newH;
|
|
4945
|
+
this.fenwick.add(index + 1, newH - oldH);
|
|
4946
|
+
return true;
|
|
4947
|
+
}
|
|
4948
|
+
/**
|
|
4949
|
+
* Finds first visible item at or after scroll offset.
|
|
4950
|
+
* Uses Fenwick binary search then adjusts for visibility.
|
|
4951
|
+
*/
|
|
4952
|
+
findFirstVisibleIndex(stRel, count) {
|
|
4953
|
+
const k = this.fenwick.lowerBoundPrefix(Math.max(0, stRel));
|
|
4954
|
+
const raw = Math.min(count - 1, k);
|
|
4955
|
+
const v = this.nextVisibleFrom(raw, count);
|
|
4956
|
+
return v === -1 ? Math.max(0, raw) : v;
|
|
4957
|
+
}
|
|
4958
|
+
/**
|
|
4959
|
+
* Inserts element into DOM maintaining index order.
|
|
4960
|
+
* Tries adjacent siblings first, then scans for insertion point.
|
|
4961
|
+
*/
|
|
4962
|
+
insertIntoHostByIndex(index, el) {
|
|
4963
|
+
el.setAttribute(VirtualRecyclerView.ATTR_INDEX, String(index));
|
|
4964
|
+
const prev = this.created.get(index - 1);
|
|
4965
|
+
if (prev?.parentElement === this.ItemsHost) {
|
|
4966
|
+
prev.after(el);
|
|
4967
|
+
return;
|
|
4968
|
+
}
|
|
4969
|
+
const next = this.created.get(index + 1);
|
|
4970
|
+
if (next?.parentElement === this.ItemsHost) {
|
|
4971
|
+
this.ItemsHost.insertBefore(el, next);
|
|
4972
|
+
return;
|
|
4973
|
+
}
|
|
4974
|
+
const children = Array.from(this.ItemsHost.children);
|
|
4975
|
+
for (const child of children) {
|
|
4976
|
+
const v = child.getAttribute(VirtualRecyclerView.ATTR_INDEX);
|
|
4977
|
+
if (v && Number(v) > index) {
|
|
4978
|
+
this.ItemsHost.insertBefore(el, child);
|
|
4979
|
+
return;
|
|
4980
|
+
}
|
|
4981
|
+
}
|
|
4982
|
+
this.ItemsHost.appendChild(el);
|
|
4983
|
+
}
|
|
4984
|
+
/**
|
|
4985
|
+
* Ensures element is in correct DOM position for its index.
|
|
4986
|
+
* Reinserts if siblings indicate wrong position.
|
|
4987
|
+
*/
|
|
4988
|
+
ensureDomOrder(index, el) {
|
|
4989
|
+
if (el.parentElement !== this.ItemsHost) {
|
|
4990
|
+
this.insertIntoHostByIndex(index, el);
|
|
4991
|
+
return;
|
|
4992
|
+
}
|
|
4993
|
+
el.setAttribute(VirtualRecyclerView.ATTR_INDEX, String(index));
|
|
4994
|
+
const prev = el.previousElementSibling;
|
|
4995
|
+
const next = el.nextElementSibling;
|
|
4996
|
+
const needsReorder = (prev && Number(prev.getAttribute(VirtualRecyclerView.ATTR_INDEX)) > index) ||
|
|
4997
|
+
(next && Number(next.getAttribute(VirtualRecyclerView.ATTR_INDEX)) < index);
|
|
4998
|
+
if (needsReorder) {
|
|
4999
|
+
el.remove();
|
|
5000
|
+
this.insertIntoHostByIndex(index, el);
|
|
5001
|
+
}
|
|
5002
|
+
}
|
|
5003
|
+
/**
|
|
5004
|
+
* Attaches ResizeObserver to measure items when they resize.
|
|
5005
|
+
* Singleton pattern - only creates once per instance.
|
|
5006
|
+
*/
|
|
5007
|
+
attachResizeObserverOnce() {
|
|
5008
|
+
if (this.resizeObs)
|
|
5009
|
+
return;
|
|
5010
|
+
this.resizeObs = new ResizeObserver(() => {
|
|
5011
|
+
if (this._suppressResize || this._suspended || !this.adapter || this._measureRaf != null)
|
|
5012
|
+
return;
|
|
5013
|
+
this._measureRaf = requestAnimationFrame(() => {
|
|
5014
|
+
this._measureRaf = null;
|
|
5015
|
+
this.measureVisibleAndUpdate();
|
|
5016
|
+
});
|
|
5017
|
+
});
|
|
5018
|
+
this.resizeObs.observe(this.ItemsHost);
|
|
5019
|
+
}
|
|
5020
|
+
/**
|
|
5021
|
+
* Measures all currently rendered items and updates height cache.
|
|
5022
|
+
* Triggers window update if any heights changed.
|
|
5023
|
+
*/
|
|
5024
|
+
measureVisibleAndUpdate() {
|
|
5025
|
+
if (!this.adapter)
|
|
5026
|
+
return;
|
|
5027
|
+
const count = this.adapter.itemCount();
|
|
5028
|
+
if (count <= 0)
|
|
5029
|
+
return;
|
|
5030
|
+
let changed = false;
|
|
5031
|
+
for (let i = this.start; i <= this.end; i++) {
|
|
5032
|
+
if (!this.isIndexVisible(i))
|
|
5033
|
+
continue;
|
|
5034
|
+
const item = this.adapter.items[i];
|
|
5035
|
+
const el = item?.view?.getView?.();
|
|
5036
|
+
if (!el)
|
|
5037
|
+
continue;
|
|
5038
|
+
const newH = this.measureOuterHeight(el);
|
|
5039
|
+
if (this.updateHeightAt(i, newH))
|
|
5040
|
+
changed = true;
|
|
5041
|
+
}
|
|
5042
|
+
if (changed) {
|
|
5043
|
+
if (this.opts.adaptiveEstimate)
|
|
5044
|
+
this.rebuildFenwick(count);
|
|
5045
|
+
this.scheduleUpdateWindow();
|
|
5046
|
+
}
|
|
5047
|
+
}
|
|
5048
|
+
/** Scroll event handler - schedules render update. */
|
|
5049
|
+
onScroll() {
|
|
5050
|
+
this.scheduleUpdateWindow();
|
|
5051
|
+
}
|
|
5052
|
+
/**
|
|
5053
|
+
* Core rendering logic - calculates and updates visible window.
|
|
5054
|
+
*
|
|
5055
|
+
* 1. Calculates viewport bounds accounting for scroll and sticky headers
|
|
5056
|
+
* 2. Uses anchor item to prevent scroll jumping during height changes
|
|
5057
|
+
* 3. Determines start/end indices with overscan buffer
|
|
5058
|
+
* 4. Mounts/unmounts items as needed
|
|
5059
|
+
* 5. Measures visible items if dynamic heights enabled
|
|
5060
|
+
* 6. Updates padding elements to maintain total scroll height
|
|
5061
|
+
* 7. Adjusts scroll position to maintain anchor item position
|
|
5062
|
+
*/
|
|
5063
|
+
updateWindowInternal() {
|
|
5064
|
+
if (this._updating || this._suspended)
|
|
5065
|
+
return;
|
|
5066
|
+
this._updating = true;
|
|
5067
|
+
try {
|
|
5068
|
+
if (!this.adapter)
|
|
5069
|
+
return;
|
|
5070
|
+
const count = this.adapter.itemCount();
|
|
5071
|
+
if (count <= 0)
|
|
5072
|
+
return;
|
|
5073
|
+
if (this._lastRenderCount !== count) {
|
|
5074
|
+
this._lastRenderCount = count;
|
|
5075
|
+
this.heightCache.length = count;
|
|
5076
|
+
this.rebuildFenwick(count);
|
|
5077
|
+
}
|
|
5078
|
+
const containerTop = this.containerTopInScroll();
|
|
5079
|
+
const stRel = Math.max(0, this.scrollEl.scrollTop - containerTop);
|
|
5080
|
+
const stickyH = this.stickyTopHeight();
|
|
5081
|
+
const vhEff = Math.max(0, this.scrollEl.clientHeight - stickyH);
|
|
5082
|
+
const anchorIndex = this.findFirstVisibleIndex(stRel, count);
|
|
5083
|
+
const anchorTop = this.offsetTopOf(anchorIndex);
|
|
5084
|
+
const anchorDelta = containerTop + anchorTop - this.scrollEl.scrollTop;
|
|
5085
|
+
const firstVis = this.findFirstVisibleIndex(stRel, count);
|
|
5086
|
+
if (firstVis === -1) {
|
|
5087
|
+
this.resetState();
|
|
5088
|
+
return;
|
|
5089
|
+
}
|
|
5090
|
+
const est = this.getEstimate();
|
|
5091
|
+
const overscanPx = this.opts.overscan * est;
|
|
5092
|
+
let startIndex = this.nextVisibleFrom(Math.min(count - 1, this.fenwick.lowerBoundPrefix(Math.max(0, stRel - overscanPx))), count) ?? firstVis;
|
|
5093
|
+
let endIndex = Math.min(count - 1, this.fenwick.lowerBoundPrefix(stRel + vhEff + overscanPx));
|
|
5094
|
+
if (startIndex === this.start && endIndex === this.end)
|
|
5095
|
+
return;
|
|
5096
|
+
this.start = startIndex;
|
|
5097
|
+
this.end = endIndex;
|
|
5098
|
+
this._suppressResize = true;
|
|
5099
|
+
try {
|
|
5100
|
+
this.mountRange(this.start, this.end);
|
|
5101
|
+
this.unmountOutside(this.start, this.end);
|
|
5102
|
+
if (this.opts.dynamicHeights)
|
|
5103
|
+
this.measureVisibleAndUpdate();
|
|
5104
|
+
const topPx = this.offsetTopOf(this.start);
|
|
5105
|
+
const windowPx = this.windowHeight(this.start, this.end);
|
|
5106
|
+
const total = this.totalHeight(count);
|
|
5107
|
+
const bottomPx = Math.max(0, total - topPx - windowPx);
|
|
5108
|
+
this.PadTop.style.height = `${topPx}px`;
|
|
5109
|
+
this.PadBottom.style.height = `${bottomPx}px`;
|
|
5110
|
+
}
|
|
5111
|
+
finally {
|
|
5112
|
+
this._suppressResize = false;
|
|
5113
|
+
}
|
|
5114
|
+
const anchorTopNew = this.offsetTopOf(anchorIndex);
|
|
5115
|
+
const targetScroll = this.containerTopInScroll() + anchorTopNew - anchorDelta;
|
|
5116
|
+
const maxScroll = Math.max(0, this.scrollEl.scrollHeight - this.scrollEl.clientHeight);
|
|
5117
|
+
const clamped = Math.min(Math.max(0, targetScroll), maxScroll);
|
|
5118
|
+
if (Math.abs(this.scrollEl.scrollTop - clamped) > 0.5) {
|
|
5119
|
+
this.scrollEl.scrollTop = clamped;
|
|
5120
|
+
}
|
|
5121
|
+
}
|
|
5122
|
+
finally {
|
|
5123
|
+
this._updating = false;
|
|
5124
|
+
}
|
|
5125
|
+
}
|
|
5126
|
+
/** Mounts all items in inclusive range [start..end]. */
|
|
5127
|
+
mountRange(start, end) {
|
|
5128
|
+
for (let i = start; i <= end; i++)
|
|
5129
|
+
this.mountIndexOnce(i);
|
|
5130
|
+
}
|
|
5131
|
+
/**
|
|
5132
|
+
* Mounts single item, reusing existing element if available.
|
|
5133
|
+
* Creates view holder on first mount, rebinds on subsequent renders.
|
|
5134
|
+
*/
|
|
5135
|
+
mountIndexOnce(index) {
|
|
5136
|
+
if (!this.isIndexVisible(index)) {
|
|
5137
|
+
const existing = this.created.get(index);
|
|
5138
|
+
if (existing?.parentElement === this.ItemsHost)
|
|
5139
|
+
existing.remove();
|
|
5140
|
+
this.created.delete(index);
|
|
5141
|
+
return;
|
|
5142
|
+
}
|
|
5143
|
+
const item = this.adapter.items[index];
|
|
5144
|
+
if (!item)
|
|
5145
|
+
return;
|
|
5146
|
+
const existing = this.created.get(index);
|
|
5147
|
+
if (existing) {
|
|
5148
|
+
if (!item?.view) {
|
|
5149
|
+
existing.remove();
|
|
5150
|
+
this.created.delete(index);
|
|
5151
|
+
}
|
|
5152
|
+
else {
|
|
5153
|
+
this.ensureDomOrder(index, existing);
|
|
5154
|
+
this.adapter.onViewHolder(item, item.view, index);
|
|
5155
|
+
}
|
|
5156
|
+
return;
|
|
5157
|
+
}
|
|
5158
|
+
if (!item.isInit) {
|
|
5159
|
+
const viewer = this.adapter.viewHolder(this.ItemsHost, item);
|
|
5160
|
+
item.view = viewer;
|
|
5161
|
+
this.adapter.onViewHolder(item, viewer, index);
|
|
5162
|
+
item.isInit = true;
|
|
5163
|
+
}
|
|
5164
|
+
else if (item.view) {
|
|
5165
|
+
this.adapter.onViewHolder(item, item.view, index);
|
|
5166
|
+
}
|
|
5167
|
+
const el = item.view?.getView?.();
|
|
5168
|
+
if (el) {
|
|
5169
|
+
this.ensureDomOrder(index, el);
|
|
5170
|
+
this.created.set(index, el);
|
|
5171
|
+
}
|
|
5172
|
+
}
|
|
5173
|
+
/** Removes all mounted items outside [start..end] range. */
|
|
5174
|
+
unmountOutside(start, end) {
|
|
5175
|
+
this.created.forEach((el, idx) => {
|
|
5176
|
+
if (idx < start || idx > end) {
|
|
5177
|
+
if (el.parentElement === this.ItemsHost)
|
|
5178
|
+
el.remove();
|
|
5179
|
+
this.created.delete(idx);
|
|
5180
|
+
}
|
|
5181
|
+
});
|
|
5182
|
+
}
|
|
5183
|
+
/** Removes all items marked as invisible from DOM. */
|
|
5184
|
+
cleanupInvisibleItems() {
|
|
5185
|
+
this.created.forEach((el, idx) => {
|
|
5186
|
+
if (!this.isIndexVisible(idx)) {
|
|
5187
|
+
if (el.parentElement === this.ItemsHost)
|
|
5188
|
+
el.remove();
|
|
5189
|
+
this.created.delete(idx);
|
|
5190
|
+
}
|
|
5191
|
+
});
|
|
5192
|
+
}
|
|
5193
|
+
/** Returns cumulative height from start to top of item at index. */
|
|
5194
|
+
offsetTopOf(index) {
|
|
5195
|
+
return this.fenwick.sum(index);
|
|
5196
|
+
}
|
|
5197
|
+
/** Returns total height of items in range [start..end]. */
|
|
5198
|
+
windowHeight(start, end) {
|
|
5199
|
+
return this.fenwick.rangeSum(start + 1, end + 1);
|
|
5200
|
+
}
|
|
5201
|
+
/** Returns total scrollable height for all items. */
|
|
5202
|
+
totalHeight(count) {
|
|
5203
|
+
return this.fenwick.sum(count);
|
|
5204
|
+
}
|
|
5205
|
+
}
|
|
5206
|
+
VirtualRecyclerView.EPS = 0.5;
|
|
5207
|
+
VirtualRecyclerView.ATTR_INDEX = "data-vindex";
|
|
5208
|
+
|
|
4484
5209
|
/**
|
|
4485
5210
|
* @class
|
|
4486
5211
|
*/
|
|
@@ -4610,7 +5335,12 @@ class SelectBox {
|
|
|
4610
5335
|
select.classList.add("init");
|
|
4611
5336
|
// ModelManager setup
|
|
4612
5337
|
optionModelManager.setupAdapter(MixedAdapter);
|
|
4613
|
-
|
|
5338
|
+
if (options.virtualScroll) {
|
|
5339
|
+
optionModelManager.setupRecyclerView(VirtualRecyclerView);
|
|
5340
|
+
}
|
|
5341
|
+
else {
|
|
5342
|
+
optionModelManager.setupRecyclerView(RecyclerView);
|
|
5343
|
+
}
|
|
4614
5344
|
optionModelManager.createModelResources(Libs.parseSelectToArray(select));
|
|
4615
5345
|
optionModelManager.onUpdated = () => {
|
|
4616
5346
|
container.popup?.triggerResize?.();
|
|
@@ -4670,6 +5400,7 @@ class SelectBox {
|
|
|
4670
5400
|
searchController.setAjax(options.ajax);
|
|
4671
5401
|
}
|
|
4672
5402
|
const optionAdapter = container.popup.optionAdapter;
|
|
5403
|
+
let hightlightTimer = null;
|
|
4673
5404
|
const searchHandle = (keyword, isTrigger) => {
|
|
4674
5405
|
if (!isTrigger && keyword === "") {
|
|
4675
5406
|
searchController.clear();
|
|
@@ -4680,12 +5411,16 @@ class SelectBox {
|
|
|
4680
5411
|
searchController
|
|
4681
5412
|
.search(keyword)
|
|
4682
5413
|
.then((result) => {
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
5414
|
+
clearTimeout(hightlightTimer);
|
|
5415
|
+
Libs.callbackScheduler.on(`sche_vis_proxy_${optionAdapter.adapterKey}`, () => {
|
|
5416
|
+
container.popup?.triggerResize?.();
|
|
5417
|
+
if (result?.hasResults) {
|
|
5418
|
+
hightlightTimer = setTimeout(() => {
|
|
5419
|
+
optionAdapter.resetHighlight();
|
|
5420
|
+
container.popup?.triggerResize?.();
|
|
5421
|
+
}, options.animationtime ?? 0);
|
|
5422
|
+
}
|
|
5423
|
+
}, { debounce: 10 });
|
|
4689
5424
|
})
|
|
4690
5425
|
.catch((error) => {
|
|
4691
5426
|
console.error("Search error:", error);
|
|
@@ -4696,16 +5431,18 @@ class SelectBox {
|
|
|
4696
5431
|
searchbox.onSearch = (keyword, isTrigger) => {
|
|
4697
5432
|
if (!searchController.compareSearchTrigger(keyword))
|
|
4698
5433
|
return;
|
|
5434
|
+
if (searchHandleTimer)
|
|
5435
|
+
clearTimeout(searchHandleTimer);
|
|
4699
5436
|
if (searchController.isAjax()) {
|
|
4700
|
-
if (searchHandleTimer)
|
|
4701
|
-
clearTimeout(searchHandleTimer);
|
|
4702
5437
|
container.popup?.showLoading?.();
|
|
4703
5438
|
searchHandleTimer = setTimeout(() => {
|
|
4704
5439
|
searchHandle(keyword, isTrigger);
|
|
4705
5440
|
}, options.delaysearchtime ?? 0);
|
|
4706
5441
|
}
|
|
4707
5442
|
else {
|
|
4708
|
-
|
|
5443
|
+
searchHandleTimer = setTimeout(() => {
|
|
5444
|
+
searchHandle(keyword, isTrigger);
|
|
5445
|
+
}, 10);
|
|
4709
5446
|
}
|
|
4710
5447
|
};
|
|
4711
5448
|
searchController.setPopup(container.popup);
|
|
@@ -5222,7 +5959,7 @@ class Selective {
|
|
|
5222
5959
|
this.bindedQueries.set(query, merged);
|
|
5223
5960
|
const doneToken = Libs.randomString();
|
|
5224
5961
|
Libs.callbackScheduler.on(doneToken, () => {
|
|
5225
|
-
iEvents.callEvent([this.find(query)], ...merged.on.load);
|
|
5962
|
+
iEvents.callEvent([this.find(query)], ...(merged.on.load));
|
|
5226
5963
|
Libs.callbackScheduler.clear(doneToken);
|
|
5227
5964
|
merged.on.load = [];
|
|
5228
5965
|
});
|
|
@@ -5516,7 +6253,7 @@ const SECLASS = new Selective();
|
|
|
5516
6253
|
*
|
|
5517
6254
|
* Declared as `const` literal type to enable strict typing and easy tree-shaking.
|
|
5518
6255
|
*/
|
|
5519
|
-
const version = "1.1
|
|
6256
|
+
const version = "1.2.1";
|
|
5520
6257
|
/**
|
|
5521
6258
|
* Library name identifier.
|
|
5522
6259
|
*
|