selective-ui 1.1.6 → 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.
Files changed (39) hide show
  1. package/dist/selective-ui.css +7 -1
  2. package/dist/selective-ui.css.map +1 -1
  3. package/dist/selective-ui.esm.js +742 -26
  4. package/dist/selective-ui.esm.js.map +1 -1
  5. package/dist/selective-ui.esm.min.js +2 -2
  6. package/dist/selective-ui.esm.min.js.br +0 -0
  7. package/dist/selective-ui.min.css +1 -1
  8. package/dist/selective-ui.min.css.br +0 -0
  9. package/dist/selective-ui.min.js +2 -2
  10. package/dist/selective-ui.min.js.br +0 -0
  11. package/dist/selective-ui.umd.js +743 -27
  12. package/dist/selective-ui.umd.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/css/components/popup.css +7 -1
  15. package/src/ts/adapter/mixed-adapter.ts +12 -7
  16. package/src/ts/components/empty-state.ts +5 -4
  17. package/src/ts/components/loading-state.ts +4 -4
  18. package/src/ts/components/option-handle.ts +4 -4
  19. package/src/ts/components/popup.ts +35 -6
  20. package/src/ts/components/searchbox.ts +2 -0
  21. package/src/ts/components/selectbox.ts +8 -1
  22. package/src/ts/core/base/adapter.ts +8 -5
  23. package/src/ts/core/base/model.ts +19 -1
  24. package/src/ts/core/base/recyclerview.ts +3 -1
  25. package/src/ts/core/base/virtual-recyclerview.ts +762 -0
  26. package/src/ts/core/model-manager.ts +24 -16
  27. package/src/ts/core/search-controller.ts +4 -4
  28. package/src/ts/services/effector.ts +2 -2
  29. package/src/ts/types/components/state.box.type.ts +1 -18
  30. package/src/ts/types/core/base/adapter.type.ts +14 -0
  31. package/src/ts/types/core/base/model.type.ts +5 -0
  32. package/src/ts/types/core/base/recyclerview.type.ts +3 -1
  33. package/src/ts/types/core/base/view.type.ts +6 -0
  34. package/src/ts/types/core/base/virtual-recyclerview.type.ts +66 -0
  35. package/src/ts/types/utils/istorage.type.ts +1 -0
  36. package/src/ts/utils/istorage.ts +3 -2
  37. package/src/ts/utils/libs.ts +26 -25
  38. package/src/ts/utils/selective.ts +7 -7
  39. package/src/ts/views/option-view.ts +8 -8
@@ -1,4 +1,4 @@
1
- /*! Selective UI v1.1.6 | MIT License */
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 {object} options - Default configuration to be merged.
406
- * @returns {object} - Final configuration after element overrides.
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 {unknown} bindMap - Value to store in the binder map.
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 {unknown} bindMap - Value to store in the unbinder map.
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.__parentGroup = group;
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)
@@ -2140,11 +2159,11 @@ class Model {
2140
2159
  * @param {TView|null} [view=null] - The associated view responsible for rendering the model.
2141
2160
  */
2142
2161
  constructor(options, targetElement = null, view = null) {
2143
- /** @type {TTarget | null} */
2144
2162
  this.targetElement = null;
2145
2163
  this.view = null;
2146
2164
  this.position = -1;
2147
2165
  this.isInit = false;
2166
+ this.isRemoved = false;
2148
2167
  this.options = options;
2149
2168
  this.targetElement = targetElement;
2150
2169
  this.view = view;
@@ -2158,11 +2177,26 @@ class Model {
2158
2177
  this.targetElement = targetElement;
2159
2178
  this.onTargetChanged();
2160
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
+ }
2161
2190
  /**
2162
2191
  * Hook invoked whenever the target element changes.
2163
2192
  * Override in subclasses to react to attribute/content updates (e.g., text, disabled state).
2164
2193
  */
2165
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() { }
2166
2200
  }
2167
2201
 
2168
2202
  /**
@@ -2612,7 +2646,7 @@ class ModelManager {
2612
2646
  // Adapter expects TModel[], but this manager's list is GroupModel|OptionModel.
2613
2647
  this._privAdapterHandle.syncFromSource(this._privModelList);
2614
2648
  }
2615
- this.refresh();
2649
+ this.refresh(false);
2616
2650
  }
2617
2651
  /**
2618
2652
  * Requests a view refresh if an adapter has been initialized,
@@ -2621,7 +2655,7 @@ class ModelManager {
2621
2655
  notify() {
2622
2656
  if (!this._privAdapterHandle)
2623
2657
  return;
2624
- this.refresh();
2658
+ this.refresh(false);
2625
2659
  }
2626
2660
  /**
2627
2661
  * Initializes adapter and recycler view instances, attaches them to a container element,
@@ -2631,8 +2665,8 @@ class ModelManager {
2631
2665
  this._privAdapterHandle = new this._privAdapter(this._privModelList);
2632
2666
  Object.assign(this._privAdapterHandle, adapterOpt);
2633
2667
  this._privRecyclerViewHandle = new this._privRecyclerView(viewElement);
2634
- this._privRecyclerViewHandle.setAdapter(this._privAdapterHandle);
2635
2668
  Object.assign(this._privRecyclerViewHandle, recyclerViewOpt);
2669
+ this._privRecyclerViewHandle.setAdapter(this._privAdapterHandle);
2636
2670
  }
2637
2671
  /**
2638
2672
  * Diffs existing models against new <optgroup>/<option> data to update in place:
@@ -2713,18 +2747,21 @@ class ModelManager {
2713
2747
  position++;
2714
2748
  }
2715
2749
  });
2750
+ let isUpdate = true;
2716
2751
  oldGroupMap.forEach((removedGroup) => {
2717
- removedGroup.view?.getView?.()?.remove?.();
2752
+ isUpdate = false;
2753
+ removedGroup.remove();
2718
2754
  });
2719
2755
  oldOptionMap.forEach((removedOption) => {
2720
- removedOption.view?.getView?.()?.remove?.();
2756
+ isUpdate = false;
2757
+ removedOption.remove();
2721
2758
  });
2722
2759
  this._privModelList = newModels;
2723
2760
  if (this._privAdapterHandle) {
2724
2761
  this._privAdapterHandle.updateData(this._privModelList);
2725
2762
  }
2726
2763
  this.onUpdated();
2727
- this.refresh();
2764
+ this.refresh(isUpdate);
2728
2765
  }
2729
2766
  /**
2730
2767
  * Hook invoked after the manager completes an update or refresh cycle.
@@ -2743,11 +2780,13 @@ class ModelManager {
2743
2780
  /**
2744
2781
  * Re-renders the recycler view if present and invokes the post-refresh hook.
2745
2782
  * No-op if the recycler view is not initialized.
2783
+ *
2784
+ * @param isUpdate - Indicates if this refresh is due to an update operation.
2746
2785
  */
2747
- refresh() {
2786
+ refresh(isUpdate) {
2748
2787
  if (!this._privRecyclerViewHandle)
2749
2788
  return;
2750
- this._privRecyclerViewHandle.refresh();
2789
+ this._privRecyclerViewHandle.refresh(isUpdate);
2751
2790
  this.onUpdated();
2752
2791
  }
2753
2792
  /**
@@ -2840,8 +2879,10 @@ class RecyclerView {
2840
2879
  /**
2841
2880
  * Forces a re-render of the current adapter state into the container.
2842
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.
2843
2884
  */
2844
- refresh() {
2885
+ refresh(isUpdate) {
2845
2886
  this.render();
2846
2887
  }
2847
2888
  }
@@ -3585,11 +3626,11 @@ class Adapter {
3585
3626
  */
3586
3627
  onViewHolder(item, viewer, position) {
3587
3628
  const v = viewer;
3588
- if (!item.isInit) {
3589
- v?.render?.();
3629
+ if (item.isInit) {
3630
+ v?.update?.();
3590
3631
  }
3591
3632
  else {
3592
- v?.update?.();
3633
+ v?.render?.();
3593
3634
  }
3594
3635
  }
3595
3636
  /**
@@ -4456,14 +4497,18 @@ class MixedAdapter extends Adapter {
4456
4497
  }
4457
4498
  for (let i = index; i < this.flatOptions.length; i++) {
4458
4499
  const item = this.flatOptions[i];
4459
- if (!item.visible)
4500
+ if (!item?.visible)
4460
4501
  continue;
4461
4502
  item.highlighted = true;
4462
4503
  this._currentHighlightIndex = i;
4463
4504
  if (isScrollToView) {
4464
4505
  const el = item.view?.getView?.();
4465
- if (el)
4466
- el.scrollIntoView({ block: "center", behavior: "smooth" });
4506
+ if (el) {
4507
+ el.scrollIntoView({ block: 'center', behavior: 'smooth' });
4508
+ }
4509
+ else {
4510
+ this.recyclerView?.ensureRendered?.(i, { scrollIntoView: true });
4511
+ }
4467
4512
  }
4468
4513
  this.onHighlightChange(i, item.view?.getView?.()?.id);
4469
4514
  return;
@@ -4481,6 +4526,671 @@ class MixedAdapter extends Adapter {
4481
4526
  onCollapsedChange(model, collapsed) { }
4482
4527
  }
4483
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
+
4484
5194
  /**
4485
5195
  * @class
4486
5196
  */
@@ -4610,7 +5320,12 @@ class SelectBox {
4610
5320
  select.classList.add("init");
4611
5321
  // ModelManager setup
4612
5322
  optionModelManager.setupAdapter(MixedAdapter);
4613
- optionModelManager.setupRecyclerView(RecyclerView);
5323
+ if (options.virtualScroll) {
5324
+ optionModelManager.setupRecyclerView(VirtualRecyclerView);
5325
+ }
5326
+ else {
5327
+ optionModelManager.setupRecyclerView(RecyclerView);
5328
+ }
4614
5329
  optionModelManager.createModelResources(Libs.parseSelectToArray(select));
4615
5330
  optionModelManager.onUpdated = () => {
4616
5331
  container.popup?.triggerResize?.();
@@ -4683,6 +5398,7 @@ class SelectBox {
4683
5398
  container.popup?.triggerResize?.();
4684
5399
  if (result?.hasResults) {
4685
5400
  setTimeout(() => {
5401
+ container.popup?.triggerResize?.();
4686
5402
  optionAdapter.resetHighlight();
4687
5403
  }, options.animationtime ? options.animationtime + 10 : 0);
4688
5404
  }
@@ -5222,7 +5938,7 @@ class Selective {
5222
5938
  this.bindedQueries.set(query, merged);
5223
5939
  const doneToken = Libs.randomString();
5224
5940
  Libs.callbackScheduler.on(doneToken, () => {
5225
- iEvents.callEvent([this.find(query)], ...merged.on.load);
5941
+ iEvents.callEvent([this.find(query)], ...(merged.on.load));
5226
5942
  Libs.callbackScheduler.clear(doneToken);
5227
5943
  merged.on.load = [];
5228
5944
  });
@@ -5516,7 +6232,7 @@ const SECLASS = new Selective();
5516
6232
  *
5517
6233
  * Declared as `const` literal type to enable strict typing and easy tree-shaking.
5518
6234
  */
5519
- const version = "1.1.6";
6235
+ const version = "1.2.0";
5520
6236
  /**
5521
6237
  * Library name identifier.
5522
6238
  *