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.
Files changed (40) 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 +794 -57
  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 +795 -58
  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 +29 -18
  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 +23 -9
  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 +763 -0
  26. package/src/ts/core/model-manager.ts +24 -16
  27. package/src/ts/core/search-controller.ts +5 -8
  28. package/src/ts/models/option-model.ts +22 -3
  29. package/src/ts/services/effector.ts +7 -7
  30. package/src/ts/types/components/state.box.type.ts +1 -18
  31. package/src/ts/types/core/base/adapter.type.ts +14 -0
  32. package/src/ts/types/core/base/model.type.ts +5 -0
  33. package/src/ts/types/core/base/recyclerview.type.ts +3 -1
  34. package/src/ts/types/core/base/view.type.ts +6 -0
  35. package/src/ts/types/core/base/virtual-recyclerview.type.ts +66 -0
  36. package/src/ts/types/utils/istorage.type.ts +1 -0
  37. package/src/ts/utils/istorage.ts +3 -2
  38. package/src/ts/utils/libs.ts +26 -25
  39. package/src/ts/utils/selective.ts +7 -7
  40. package/src/ts/views/option-view.ts +8 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "selective-ui",
3
- "version": "1.1.6",
3
+ "version": "1.2.1",
4
4
  "description": "An overlay for the HTML select element.",
5
5
  "author": "Huỳnh Công Xuân Mai",
6
6
  "license": "MIT",
@@ -28,7 +28,6 @@
28
28
  display: flex;
29
29
  flex-direction: column;
30
30
  width: 100%;
31
- gap: 2px;
32
31
 
33
32
  -webkit-overflow-scrolling: touch;
34
33
  touch-action: pan-y;
@@ -36,4 +35,11 @@
36
35
 
37
36
  .selective-ui-options-container.hide {
38
37
  display: none;
38
+ }
39
+
40
+ .selective-ui-options-container .selective-ui-virtual-items {
41
+ display: flex;
42
+ flex-direction: column;
43
+ width: 100%;
44
+ gap: 2px;
39
45
  }
@@ -5,6 +5,8 @@ import { GroupView } from "../views/group-view";
5
5
  import { OptionView } from "../views/option-view";
6
6
  import { MixedItem, VisibilityStats } from "../types/core/base/mixed-adapter.type";
7
7
  import { IEventCallback } from "../types/utils/ievents.type";
8
+ import { ImagePosition, LabelHalign, LabelValign } from "../types/views/view.option.type";
9
+ import { Libs } from "../utils/libs";
8
10
 
9
11
  /**
10
12
  * @extends {Adapter<GroupModel|OptionModel>}
@@ -24,6 +26,21 @@ export class MixedAdapter extends Adapter<MixedItem, GroupView | OptionView> {
24
26
  constructor(items: MixedItem[] = []) {
25
27
  super(items);
26
28
  this._buildFlatStructure();
29
+
30
+ Libs.callbackScheduler.on(`sche_vis_${this.adapterKey}`, () => {
31
+ const visibleCount = this.flatOptions.filter((item) => item.visible).length;
32
+ const totalCount = this.flatOptions.length;
33
+
34
+ this._visibilityChangedCallbacks.forEach((callback) => {
35
+ callback({
36
+ visibleCount,
37
+ totalCount,
38
+ hasVisible: visibleCount > 0,
39
+ isEmpty: totalCount === 0,
40
+ });
41
+ });
42
+ Libs.callbackScheduler.run(`sche_vis_proxy_${this.adapterKey}`);
43
+ }, {debounce: 10});
27
44
  }
28
45
 
29
46
  /**
@@ -84,7 +101,7 @@ export class MixedAdapter extends Adapter<MixedItem, GroupView | OptionView> {
84
101
  * @param {number} position - The position (index) of the group within a list.
85
102
  */
86
103
  private _handleGroupView(groupModel: GroupModel, groupView: GroupView, position: number): void {
87
- super.onViewHolder(groupModel as any, groupView as any, position);
104
+ super.onViewHolder(groupModel, groupView, position);
88
105
  groupModel.view = groupView;
89
106
 
90
107
  const header = groupView.view.tags.GroupHeader;
@@ -141,13 +158,13 @@ export class MixedAdapter extends Adapter<MixedItem, GroupView | OptionView> {
141
158
  imageWidth: optionModel.options.imageWidth as string,
142
159
  imageHeight: optionModel.options.imageHeight as string,
143
160
  imageBorderRadius: optionModel.options.imageBorderRadius as string,
144
- imagePosition: optionModel.options.imagePosition as any,
145
- labelValign: optionModel.options.labelValign as any,
146
- labelHalign: optionModel.options.labelHalign as any,
161
+ imagePosition: optionModel.options.imagePosition as ImagePosition,
162
+ labelValign: optionModel.options.labelValign as LabelValign,
163
+ labelHalign: optionModel.options.labelHalign as LabelHalign,
147
164
  };
148
165
 
149
166
  if (!optionModel.isInit) {
150
- super.onViewHolder(optionModel as any, optionViewer as any, position);
167
+ super.onViewHolder(optionModel, optionViewer, position);
151
168
  }
152
169
 
153
170
  optionModel.view = optionViewer;
@@ -288,17 +305,7 @@ export class MixedAdapter extends Adapter<MixedItem, GroupView | OptionView> {
288
305
  * Computes visible and total counts, then emits aggregated state.
289
306
  */
290
307
  private _notifyVisibilityChanged(): void {
291
- const visibleCount = this.flatOptions.filter((item) => item.visible).length;
292
- const totalCount = this.flatOptions.length;
293
-
294
- this._visibilityChangedCallbacks.forEach((callback) => {
295
- callback({
296
- visibleCount,
297
- totalCount,
298
- hasVisible: visibleCount > 0,
299
- isEmpty: totalCount === 0,
300
- });
301
- });
308
+ Libs.callbackScheduler.run(`sche_vis_${this.adapterKey}`);
302
309
  }
303
310
 
304
311
  /**
@@ -387,14 +394,18 @@ export class MixedAdapter extends Adapter<MixedItem, GroupView | OptionView> {
387
394
 
388
395
  for (let i = index; i < this.flatOptions.length; i++) {
389
396
  const item = this.flatOptions[i];
390
- if (!item.visible) continue;
397
+ if (!item?.visible) continue;
391
398
 
392
399
  item.highlighted = true;
393
400
  this._currentHighlightIndex = i;
394
401
 
395
402
  if (isScrollToView) {
396
403
  const el = item.view?.getView?.();
397
- if (el) el.scrollIntoView({ block: "center", behavior: "smooth" });
404
+ if (el) {
405
+ el.scrollIntoView({ block: 'center', behavior: 'smooth' });
406
+ } else {
407
+ this.recyclerView?.ensureRendered?.(i, { scrollIntoView: true });
408
+ }
398
409
  }
399
410
 
400
411
  this.onHighlightChange(i, item.view?.getView?.()?.id);
@@ -1,4 +1,5 @@
1
- import { EmptyStateOptions, EmptyStateType } from "../types/components/state.box.type";
1
+ import { EmptyStateType } from "../types/components/state.box.type";
2
+ import { SelectiveOptions } from "../types/utils/selective.type";
2
3
  import { Libs } from "../utils/libs";
3
4
 
4
5
  /**
@@ -7,13 +8,13 @@ import { Libs } from "../utils/libs";
7
8
  export class EmptyState {
8
9
  node: HTMLDivElement | null = null;
9
10
 
10
- options: EmptyStateOptions | null = null;
11
+ options: SelectiveOptions | null = null;
11
12
 
12
13
  /**
13
14
  * Represents an empty state component that displays a message when no data or search results are available.
14
15
  * Provides methods to show/hide the state and check its visibility.
15
16
  */
16
- constructor(options: EmptyStateOptions | null = null) {
17
+ constructor(options: SelectiveOptions | null = null) {
17
18
  if (options) this.init(options);
18
19
  }
19
20
 
@@ -22,7 +23,7 @@ export class EmptyState {
22
23
  *
23
24
  * @param {object} options - Configuration object containing text for "no data" and "not found" states.
24
25
  */
25
- init(options: EmptyStateOptions): void {
26
+ init(options: SelectiveOptions): void {
26
27
  this.options = options;
27
28
 
28
29
  this.node = Libs.nodeCreator({
@@ -1,4 +1,4 @@
1
- import { LoadingStateOptions } from "../types/components/state.box.type";
1
+ import { SelectiveOptions } from "../types/utils/selective.type";
2
2
  import { Libs } from "../utils/libs";
3
3
 
4
4
  /**
@@ -7,13 +7,13 @@ import { Libs } from "../utils/libs";
7
7
  export class LoadingState {
8
8
  node: HTMLDivElement | null = null;
9
9
 
10
- options: LoadingStateOptions | null = null;
10
+ options: SelectiveOptions | null = null;
11
11
 
12
12
  /**
13
13
  * Represents a loading state component that displays a loading message during data fetch or processing.
14
14
  * Provides methods to show/hide the state and check its visibility.
15
15
  */
16
- constructor(options: LoadingStateOptions | null = null) {
16
+ constructor(options: SelectiveOptions | null = null) {
17
17
  if (options) this.init(options);
18
18
  }
19
19
 
@@ -22,7 +22,7 @@ export class LoadingState {
22
22
  *
23
23
  * @param {object} options - Configuration object containing text for the loading message.
24
24
  */
25
- init(options: LoadingStateOptions): void {
25
+ init(options: SelectiveOptions): void {
26
26
  this.options = options;
27
27
 
28
28
  this.node = Libs.nodeCreator({
@@ -1,5 +1,5 @@
1
- import { DefaultConfig } from "../types/utils/istorage.type";
2
1
  import { MountViewResult } from "../types/utils/libs.type";
2
+ import { SelectiveOptions } from "../types/utils/selective.type";
3
3
  import { iEvents } from "../utils/ievents";
4
4
  import { Libs } from "../utils/libs";
5
5
 
@@ -11,7 +11,7 @@ export class OptionHandle {
11
11
 
12
12
  node: HTMLDivElement | null = null;
13
13
 
14
- options: DefaultConfig | null = null;
14
+ options: SelectiveOptions | null = null;
15
15
 
16
16
  private _ActionOnSelectAll: Array<(...args: unknown[]) => unknown> = [];
17
17
 
@@ -22,7 +22,7 @@ export class OptionHandle {
22
22
  * for multiple-selection lists. Includes methods to show/hide the handle, refresh its visibility,
23
23
  * and register callbacks for select/deselect events.
24
24
  */
25
- constructor(options: DefaultConfig | null = null) {
25
+ constructor(options: SelectiveOptions | null = null) {
26
26
  if (options) this.init(options);
27
27
  }
28
28
 
@@ -32,7 +32,7 @@ export class OptionHandle {
32
32
  *
33
33
  * @param {object} options - Configuration object containing text labels and feature flags.
34
34
  */
35
- init(options: DefaultConfig): void {
35
+ init(options: SelectiveOptions): void {
36
36
  this.nodeMounted = Libs.mountNode({
37
37
  OptionHandle: {
38
38
  tag: { node: "div", classList: ["selective-ui-option-handle", "hide"] },
@@ -20,6 +20,13 @@ type ParentBinderMapLike = {
20
20
  [key: string]: unknown;
21
21
  };
22
22
 
23
+ interface VirtualRecyclerOptions {
24
+ scrollEl?: HTMLElement;
25
+ estimateItemHeight?: number;
26
+ overscan?: number;
27
+ dynamicHeights?: boolean;
28
+ }
29
+
23
30
  /**
24
31
  * @class
25
32
  */
@@ -54,6 +61,12 @@ export class Popup {
54
61
 
55
62
  private _hideLoadHandle: ReturnType<typeof setTimeout> | null = null;
56
63
 
64
+ private virtualScrollConfig = {
65
+ estimateItemHeight: 36,
66
+ overscan: 8,
67
+ dynamicHeights: true
68
+ };
69
+
57
70
  /**
58
71
  * Represents a popup component that manages rendering and interaction for a dropdown panel.
59
72
  * Stores a reference to the ModelManager for handling option models and adapter logic.
@@ -84,9 +97,9 @@ export class Popup {
84
97
  init(select: HTMLSelectElement, options: SelectiveOptions): void {
85
98
  if (!this._modelManager) throw new Error("Popup requires a ModelManager instance.");
86
99
 
87
- this.optionHandle = new OptionHandle(options as any);
88
- this.emptyState = new EmptyState(options as any);
89
- this.loadingState = new LoadingState(options as any);
100
+ this.optionHandle = new OptionHandle(options);
101
+ this.emptyState = new EmptyState(options);
102
+ this.loadingState = new LoadingState(options);
90
103
 
91
104
  const nodeMounted = Libs.mountNode(
92
105
  {
@@ -112,7 +125,7 @@ export class Popup {
112
125
  },
113
126
  },
114
127
  null
115
- ) as any;
128
+ );
116
129
 
117
130
  this.node = nodeMounted.view as HTMLDivElement;
118
131
  this._optionsContainer = nodeMounted.tags.OptionsContainer as HTMLDivElement;
@@ -120,8 +133,19 @@ export class Popup {
120
133
  this._parent = Libs.getBinderMap(select) as ParentBinderMapLike | null;
121
134
  this.options = options;
122
135
 
136
+
137
+ const recyclerViewOpt = options.virtualScroll
138
+ ? {
139
+ scrollEl: this.node,
140
+ estimateItemHeight: this.virtualScrollConfig.estimateItemHeight,
141
+ overscan: this.virtualScrollConfig.overscan,
142
+ dynamicHeights: this.virtualScrollConfig.dynamicHeights }
143
+ : {}
144
+ ;
145
+
146
+
123
147
  // Load ModelManager resources into container
124
- this._modelManager.load(this._optionsContainer, { isMultiple: options.multiple });
148
+ this._modelManager.load<VirtualRecyclerOptions>(this._optionsContainer, { isMultiple: options.multiple }, recyclerViewOpt);
125
149
 
126
150
  const MMResources = this._modelManager.getResources() as {
127
151
  adapter: MixedAdapter;
@@ -294,6 +318,9 @@ export class Popup {
294
318
 
295
319
  this._resizeObser.connect(this._parent.container.tags.ViewPanel);
296
320
  callback?.();
321
+
322
+ const rv: any = this.recyclerView;
323
+ rv?.resume?.();
297
324
  },
298
325
  });
299
326
  }
@@ -304,6 +331,8 @@ export class Popup {
304
331
  */
305
332
  close(callback: (() => void) | null = null): void {
306
333
  if (!this.isCreated || !this.options || !this._resizeObser || !this._effSvc) return;
334
+ const rv: any = this.recyclerView;
335
+ rv?.suspend?.();
307
336
 
308
337
  this._resizeObser.disconnect();
309
338
  this._effSvc.collapse({
@@ -388,7 +417,7 @@ export class Popup {
388
417
  this._resizeObser = null;
389
418
 
390
419
  try {
391
- this._effSvc?.setElement?.(null as any);
420
+ this._effSvc?.setElement?.(null);
392
421
  } catch (_) {}
393
422
  this._effSvc = null;
394
423
 
@@ -96,6 +96,8 @@ export class SearchBox {
96
96
  isControlKey = true;
97
97
  this.onEsc?.();
98
98
  }
99
+
100
+ e.stopPropagation();
99
101
  });
100
102
 
101
103
  inputEl.addEventListener("input", () => {
@@ -24,6 +24,7 @@ import { BinderMap } from "../types/utils/istorage.type";
24
24
  import { ContainerRuntime, SelectBoxAction } from "../types/components/searchbox.type";
25
25
  import { AjaxConfig } from "../types/core/search-controller.type";
26
26
  import { Selective } from "../utils/selective";
27
+ import { VirtualRecyclerView } from "../core/base/virtual-recyclerview";
27
28
 
28
29
  /**
29
30
  * @class
@@ -174,7 +175,12 @@ export class SelectBox {
174
175
 
175
176
  // ModelManager setup
176
177
  optionModelManager.setupAdapter(MixedAdapter);
177
- optionModelManager.setupRecyclerView(RecyclerView);
178
+ if (options.virtualScroll) {
179
+ optionModelManager.setupRecyclerView(VirtualRecyclerView);
180
+ }
181
+ else {
182
+ optionModelManager.setupRecyclerView(RecyclerView);
183
+ }
178
184
  optionModelManager.createModelResources(Libs.parseSelectToArray(select));
179
185
 
180
186
  optionModelManager.onUpdated = () => {
@@ -245,6 +251,7 @@ export class SelectBox {
245
251
  }
246
252
 
247
253
  const optionAdapter = container.popup!.optionAdapter as MixedAdapter;
254
+ let hightlightTimer : ReturnType<typeof setTimeout> | null = null;
248
255
 
249
256
  const searchHandle = (keyword: string, isTrigger: boolean) => {
250
257
  if (!isTrigger && keyword === "") {
@@ -255,12 +262,17 @@ export class SelectBox {
255
262
  searchController
256
263
  .search(keyword)
257
264
  .then((result: any) => {
258
- container.popup?.triggerResize?.();
259
- if (result?.hasResults) {
260
- setTimeout(() => {
261
- optionAdapter.resetHighlight();
262
- }, options.animationtime ? options.animationtime + 10 : 0);
263
- }
265
+ clearTimeout(hightlightTimer!);
266
+ Libs.callbackScheduler.on(`sche_vis_proxy_${optionAdapter.adapterKey}`, () => {
267
+ container.popup?.triggerResize?.();
268
+
269
+ if (result?.hasResults) {
270
+ hightlightTimer = setTimeout(() => {
271
+ optionAdapter.resetHighlight();
272
+ container.popup?.triggerResize?.();
273
+ }, options.animationtime ?? 0);
274
+ }
275
+ }, { debounce: 10 });
264
276
  })
265
277
  .catch((error: unknown) => {
266
278
  console.error("Search error:", error);
@@ -272,15 +284,17 @@ export class SelectBox {
272
284
 
273
285
  searchbox.onSearch = (keyword: string, isTrigger: boolean) => {
274
286
  if (!searchController.compareSearchTrigger(keyword)) return;
287
+ if (searchHandleTimer) clearTimeout(searchHandleTimer);
275
288
 
276
289
  if (searchController.isAjax()) {
277
- if (searchHandleTimer) clearTimeout(searchHandleTimer);
278
290
  container.popup?.showLoading?.();
279
291
  searchHandleTimer = setTimeout(() => {
280
292
  searchHandle(keyword, isTrigger);
281
293
  }, options.delaysearchtime ?? 0);
282
294
  } else {
283
- searchHandle(keyword, isTrigger);
295
+ searchHandleTimer = setTimeout(() => {
296
+ searchHandle(keyword, isTrigger);
297
+ }, 10);
284
298
  }
285
299
  };
286
300
 
@@ -1,6 +1,7 @@
1
1
  import { Libs } from "../../utils/libs";
2
2
  import type { ModelContract } from "../../types/core/base/model.type";
3
3
  import type { AdapterContract } from "../../types/core/base/adapter.type";
4
+ import { ViewContract } from "src/ts/types/core/base/view.type";
4
5
 
5
6
  /**
6
7
  * @template TItem
@@ -9,13 +10,15 @@ import type { AdapterContract } from "../../types/core/base/adapter.type";
9
10
  */
10
11
  export class Adapter<
11
12
  TItem extends ModelContract<any, any> & { view: TViewer | null; isInit: boolean },
12
- TViewer = unknown
13
+ TViewer extends ViewContract<any>
13
14
  > implements AdapterContract<TItem> {
14
15
  items: TItem[] = [];
15
16
 
16
17
  adapterKey = Libs.randomString(12);
17
18
 
18
19
  isSkipEvent = false;
20
+
21
+ recyclerView: any;
19
22
 
20
23
  /**
21
24
  * Initializes the adapter with an optional array of items and invokes onInit()
@@ -45,11 +48,11 @@ export class Adapter<
45
48
  onViewHolder(item: TItem, viewer: TViewer | null, position: number): void {
46
49
  void position;
47
50
 
48
- const v = viewer as any;
49
- if (!item.isInit) {
50
- v?.render?.();
51
- } else {
51
+ const v = viewer;
52
+ if (item.isInit) {
52
53
  v?.update?.();
54
+ } else {
55
+ v?.render?.();
53
56
  }
54
57
  }
55
58
 
@@ -13,7 +13,6 @@ export class Model<
13
13
  TView extends ViewContract<TTags>,
14
14
  TOptions = unknown
15
15
  > implements ModelContract<TTarget, TView> {
16
- /** @type {TTarget | null} */
17
16
  targetElement: TTarget | null = null;
18
17
 
19
18
  options: TOptions;
@@ -24,6 +23,8 @@ export class Model<
24
23
 
25
24
  isInit = false;
26
25
 
26
+ isRemoved = false;
27
+
27
28
  /**
28
29
  * Returns the current value from the underlying target element's "value" attribute.
29
30
  * For single-select, this is typically a string; for multi-select, may be an array depending on usage.
@@ -56,9 +57,26 @@ export class Model<
56
57
  this.onTargetChanged();
57
58
  }
58
59
 
60
+ /**
61
+ * Cleans up references and invokes the removal hook when the model is no longer needed.
62
+ */
63
+ remove() {
64
+ this.targetElement = null;
65
+ this.view?.getView()?.remove?.();
66
+ this.view = null;
67
+ this.isRemoved = true;
68
+ this.onRemove();
69
+ }
70
+
59
71
  /**
60
72
  * Hook invoked whenever the target element changes.
61
73
  * Override in subclasses to react to attribute/content updates (e.g., text, disabled state).
62
74
  */
63
75
  onTargetChanged(): void { }
76
+
77
+ /**
78
+ * Hook invoked whenever the target element is removed.
79
+ * Override in subclasses to react to removal of the element.
80
+ */
81
+ onRemove(): void {}
64
82
  }
@@ -76,8 +76,10 @@ export class RecyclerView<
76
76
  /**
77
77
  * Forces a re-render of the current adapter state into the container.
78
78
  * Useful when visual updates are required without changing the data.
79
+ *
80
+ * @param isUpdate - Indicates if this refresh is due to an update operation.
79
81
  */
80
- refresh(): void {
82
+ refresh(isUpdate: boolean): void {
81
83
  this.render();
82
84
  }
83
85
  }