selective-ui 1.2.3 → 1.2.5

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 (59) hide show
  1. package/dist/selective-ui.css.map +1 -1
  2. package/dist/selective-ui.esm.js +5462 -1043
  3. package/dist/selective-ui.esm.js.map +1 -1
  4. package/dist/selective-ui.esm.min.js +2 -2
  5. package/dist/selective-ui.esm.min.js.br +0 -0
  6. package/dist/selective-ui.min.js +2 -2
  7. package/dist/selective-ui.min.js.br +0 -0
  8. package/dist/selective-ui.umd.js +5463 -1044
  9. package/dist/selective-ui.umd.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/ts/adapter/mixed-adapter.ts +312 -65
  12. package/src/ts/components/accessorybox.ts +248 -28
  13. package/src/ts/components/directive.ts +91 -11
  14. package/src/ts/components/option-handle.ts +191 -28
  15. package/src/ts/components/placeholder.ts +111 -16
  16. package/src/ts/components/popup/empty-state.ts +162 -0
  17. package/src/ts/components/popup/loading-state.ts +160 -0
  18. package/src/ts/components/{popup.ts → popup/popup.ts} +167 -71
  19. package/src/ts/components/searchbox.ts +225 -20
  20. package/src/ts/components/selectbox.ts +498 -120
  21. package/src/ts/core/base/adapter.ts +200 -53
  22. package/src/ts/core/base/fenwick.ts +147 -0
  23. package/src/ts/core/base/lifecycle.ts +258 -0
  24. package/src/ts/core/base/model.ts +120 -31
  25. package/src/ts/core/base/recyclerview.ts +55 -18
  26. package/src/ts/core/base/view.ts +87 -19
  27. package/src/ts/core/base/virtual-recyclerview.ts +475 -202
  28. package/src/ts/core/model-manager.ts +166 -85
  29. package/src/ts/core/search-controller.ts +236 -38
  30. package/src/ts/global.ts +6 -6
  31. package/src/ts/index.ts +6 -6
  32. package/src/ts/models/group-model.ts +159 -32
  33. package/src/ts/models/option-model.ts +213 -54
  34. package/src/ts/services/dataset-observer.ts +72 -10
  35. package/src/ts/services/ea-observer.ts +92 -15
  36. package/src/ts/services/effector.ts +181 -32
  37. package/src/ts/services/refresher.ts +30 -6
  38. package/src/ts/services/resize-observer.ts +132 -15
  39. package/src/ts/services/select-observer.ts +115 -50
  40. package/src/ts/types/components/searchbox.type.ts +1 -1
  41. package/src/ts/types/core/base/adapter.type.ts +2 -1
  42. package/src/ts/types/core/base/lifecycle.type.ts +62 -0
  43. package/src/ts/types/core/base/model.type.ts +3 -1
  44. package/src/ts/types/core/base/recyclerview.type.ts +2 -8
  45. package/src/ts/types/core/base/view.type.ts +36 -24
  46. package/src/ts/types/utils/ievents.type.ts +6 -1
  47. package/src/ts/utils/callback-scheduler.ts +112 -34
  48. package/src/ts/utils/ievents.ts +91 -29
  49. package/src/ts/utils/istorage.ts +1 -1
  50. package/src/ts/utils/selective.ts +474 -88
  51. package/src/ts/views/group-view.ts +170 -21
  52. package/src/ts/views/option-view.ts +349 -68
  53. package/src/ts/components/empty-state.ts +0 -68
  54. package/src/ts/components/loading-state.ts +0 -66
  55. /package/src/css/components/{empty-state.css → popup/empty-state.css} +0 -0
  56. /package/src/css/components/{loading-state.css → popup/loading-state.css} +0 -0
  57. /package/src/css/components/{popup.css → popup/popup.css} +0 -0
  58. /package/src/css/{components/optgroup.css → views/group-view.css} +0 -0
  59. /package/src/css/{components/option.css → views/option-view.css} +0 -0
@@ -1,21 +1,58 @@
1
-
2
1
  import { GroupModel } from "../models/group-model";
3
2
  import { OptionModel } from "../models/option-model";
3
+ import { LifecycleState } from "../types/core/base/lifecycle.type";
4
+ import { MixedItem } from "../types/core/base/mixed-adapter.type";
4
5
  import { ModelContract } from "../types/core/base/model.type";
5
6
  import { RecyclerViewContract } from "../types/core/base/recyclerview.type";
6
7
  import { ViewContract } from "../types/core/base/view.type";
7
8
  import { SelectiveOptions } from "../types/utils/selective.type";
8
9
  import { Adapter } from "./base/adapter";
10
+ import { Lifecycle } from "./base/lifecycle";
9
11
 
10
12
  /**
11
- * @template TModel
12
- * @template TAdapter
13
+ * Headless orchestrator for model creation/reconciliation and wiring of the view layer.
14
+ *
15
+ * ### Responsibilities
16
+ * - Build and maintain an ordered list of models ({@link GroupModel} / {@link OptionModel})
17
+ * from raw `<optgroup>` / `<option>` elements.
18
+ * - Own the {@link Adapter} and {@link RecyclerViewContract} instances and propagate updates/refreshes.
19
+ * - Provide a small event pipeline surface by delegating to adapter pre-/post-change hooks.
20
+ *
21
+ * **Lifecycle (Strict FSM)**
22
+ * - `NEW` → `INITIALIZED` (via constructor which calls `init()`).
23
+ * - `MOUNTED` is entered automatically on the first `createModelResources()` when state is `INITIALIZED`.
24
+ * - Subsequent calls to `refresh()`/`updateModel()` drive the `UPDATED` phase.
25
+ * - `DESTROYED` releases resources; further calls become **no-ops** where specified.
26
+ *
27
+ * **Idempotency / No-ops**
28
+ * - `createModelResources()` recreates the internal list deterministically for the given input.
29
+ * - `notify()`/`refresh()` are **no-ops** if required handles are not initialized.
30
+ * - `destroy()` is idempotent once the object is `DESTROYED`.
31
+ *
32
+ * **Relationships**
33
+ * - Consumes raw DOM-derived inputs, produces {@link GroupModel}/{@link OptionModel}.
34
+ * - Feeds the models into an {@link Adapter} which is set on a {@link RecyclerViewContract}.
35
+ * - Does not touch DOM directly; DOM side-effects are handled by the recycler view/renderer.
36
+ *
37
+ * **Events / Hooks**
38
+ * - Exposes `triggerChanging()` and `triggerChanged()` which delegate to adapter pipelines
39
+ * (`Adapter#changingProp`, `Adapter#changeProp`) for external observers.
40
+ * - Uses `skipEvent()` to temporarily suppress adapter event propagation (internal batch updates).
41
+ *
42
+ * @template TModel extends ModelContract<any, any> - Concrete model type used by the adapter.
43
+ * @template TAdapter extends Adapter<TModel, ViewContract<any>> - Concrete adapter that consumes the models.
44
+ * @extends Lifecycle
45
+ * @see {@link Adapter}
46
+ * @see {@link RecyclerViewContract}
47
+ * @see {@link GroupModel}
48
+ * @see {@link OptionModel}
49
+ * @see {@link Lifecycle}
13
50
  */
14
51
  export class ModelManager<
15
52
  TModel extends ModelContract<any, any>,
16
53
  TAdapter extends Adapter<TModel, ViewContract<any>>
17
- > {
18
- private privModelList: Array<GroupModel | OptionModel> = [];
54
+ > extends Lifecycle {
55
+ private privModelList: Array<MixedItem> = [];
19
56
 
20
57
  private privAdapter!: new (...args: any[]) => TAdapter;
21
58
 
@@ -25,25 +62,29 @@ export class ModelManager<
25
62
 
26
63
  private privRecyclerViewHandle: RecyclerViewContract<TAdapter> | null = null;
27
64
 
28
- private lastFingerprint: string | null = null;
29
-
30
65
  private options: SelectiveOptions = null;
31
66
 
32
67
  private oldPosition = 0;
33
68
 
34
69
  /**
35
70
  * Constructs a ModelManager with configuration options used by created models and components.
71
+ * Transitions lifecycle `NEW → INITIALIZED` via {@link Lifecycle.init}.
36
72
  *
37
- * @param {object} options - Configuration object passed to GroupModel/OptionModel and view infrastructure.
73
+ * @param {SelectiveOptions} options - Configuration object passed to {@link GroupModel}/{@link OptionModel}
74
+ * and to view infrastructure through adapter/recycler.
38
75
  */
39
76
  public constructor(options: SelectiveOptions) {
77
+ super();
40
78
  this.options = options;
79
+ this.init();
41
80
  }
42
81
 
43
82
  /**
44
83
  * Registers the adapter class to be used for rendering and managing models.
84
+ * Must be called before {@link load}.
45
85
  *
46
- * @param {new TAdapter} adapter - The adapter constructor (class) to instantiate.
86
+ * @param {new (...args: any[]) => TAdapter} adapter - The adapter constructor (class) to instantiate.
87
+ * @returns {void}
47
88
  */
48
89
  public setupAdapter(adapter: new (...args: any[]) => TAdapter): void {
49
90
  this.privAdapter = adapter;
@@ -51,67 +92,32 @@ export class ModelManager<
51
92
 
52
93
  /**
53
94
  * Registers the RecyclerView class responsible for hosting and updating item views.
95
+ * Must be called before {@link load}.
54
96
  *
55
- * @param {new RecyclerViewContract<TAdapter>} recyclerView - The recycler view constructor.
97
+ * @param {new (...args: any[]) => RecyclerViewContract<TAdapter>} recyclerView - The recycler view constructor.
98
+ * @returns {void}
56
99
  */
57
100
  public setupRecyclerView(recyclerView: new (...args: any[]) => RecyclerViewContract<TAdapter>): void {
58
101
  this.privRecyclerView = recyclerView;
59
102
  }
60
103
 
61
104
  /**
62
- * Checks whether the provided model data differs from the last recorded fingerprint.
63
- * Computes a new fingerprint and compares it to the previous one; if different,
64
- * updates the stored fingerprint and returns true, otherwise returns false.
65
- *
66
- * @param {Array<HTMLOptionElement|HTMLOptGroupElement>} modelData - The current model data (options/optgroups).
67
- * @returns {boolean} True if there are real changes; false otherwise.
68
- */
69
- private hasRealChanges(modelData: Array<HTMLOptionElement | HTMLOptGroupElement>): boolean {
70
- const newFingerprint = this.createFingerprint(modelData);
71
- const hasChanges = newFingerprint !== this.lastFingerprint;
72
-
73
- if (hasChanges) this.lastFingerprint = newFingerprint;
74
-
75
- return hasChanges;
76
- }
77
-
78
- /**
79
- * Produces a stable string fingerprint for the given model data.
80
- * For <optgroup>, includes the label and a pipe-joined hash of its child options
81
- * (value:text:selected). For plain <option>, includes its value, text, and selected state.
82
- * The entire list is joined by '\n\n' to form the final fingerprint.
83
- *
84
- * @param {Array<HTMLOptionElement|HTMLOptGroupElement>} modelData - The current model data to fingerprint.
85
- * @returns {string} A deterministic fingerprint representing the structure and selection state.
86
- */
87
- private createFingerprint(modelData: Array<HTMLOptionElement | HTMLOptGroupElement>): string {
88
- return modelData
89
- .map((item) => {
90
- if (item.tagName === "OPTGROUP") {
91
- const group = item as HTMLOptGroupElement;
92
- const optionsHash = Array.from(group.children)
93
- .map((opt) => {
94
- const o = opt as HTMLOptionElement;
95
- return `${o.value}:${o.text}:${o.selected}`;
96
- })
97
- .join("\n");
98
- return `G:${group.label}:${optionsHash}`;
99
- } else {
100
- const oItem = item as HTMLOptionElement;
101
- return `O:${oItem.value}:${oItem.text}:${oItem.selected}`;
102
- }
103
- })
104
- .join("\n\n");
105
- }
106
-
107
- /**
108
- * Builds model instances (GroupModel/OptionModel) from raw <optgroup>/<option> elements.
105
+ * Builds model instances ({@link GroupModel}/{@link OptionModel}) from raw `<optgroup>`/`<option>` elements.
109
106
  * Preserves grouping relationships and returns the structured list.
110
107
  *
111
- * @param {Array<HTMLOptGroupElement|HTMLOptionElement>} modelData - Parsed DOM elements from the source <select>.
112
- * @returns {Array<GroupModel|OptionModel>} - The ordered list of group and option models.
108
+ * **Behavior**
109
+ * - When called while state is `INITIALIZED`, this method performs a one-time `mount()` (auto-mount).
110
+ * - Uses a simple in-order traversal; the current group is the last seen `<optgroup>`.
111
+ * - For options, the parent is inferred via `__parentGroup` identity when available.
112
+ *
113
+ * @param {Array<HTMLOptGroupElement | HTMLOptionElement>} modelData - Parsed DOM elements from the source `<select>`.
114
+ * @returns {Array<GroupModel | OptionModel>} The ordered list of group and option models.
113
115
  */
114
116
  public createModelResources(modelData: Array<HTMLOptGroupElement | HTMLOptionElement>): Array<GroupModel | OptionModel> {
117
+ if (this.is(LifecycleState.INITIALIZED)) {
118
+ this.mount();
119
+ }
120
+
115
121
  this.privModelList = [];
116
122
  let currentGroup: GroupModel | null = null;
117
123
 
@@ -142,10 +148,15 @@ export class ModelManager<
142
148
  * Replaces the current model list with new data and syncs it into the adapter,
143
149
  * then refreshes the view to reflect changes.
144
150
  *
145
- * @param {Array<HTMLOptGroupElement|HTMLOptionElement>} modelData - New source elements to rebuild models from.
151
+ * **Notes**
152
+ * - If the adapter is not yet initialized, syncing is skipped (safe no-op).
153
+ * - After sync, calls {@link refresh} with `isUpdate = false`.
154
+ *
155
+ * @param {Array<HTMLOptGroupElement | HTMLOptionElement>} modelData - New source elements to rebuild models from.
156
+ * @returns {Promise<void>} Resolves when the adapter (if any) completes syncing.
157
+ * @see Adapter#syncFromSource
146
158
  */
147
159
  public async replace(modelData: Array<HTMLOptGroupElement | HTMLOptionElement>): Promise<void> {
148
- this.lastFingerprint = null;
149
160
  this.createModelResources(modelData);
150
161
 
151
162
  if (this.privAdapterHandle) {
@@ -159,6 +170,9 @@ export class ModelManager<
159
170
  /**
160
171
  * Requests a view refresh if an adapter has been initialized,
161
172
  * typically used after external updates to model data.
173
+ * **No-op** if the adapter is absent.
174
+ *
175
+ * @returns {void}
162
176
  */
163
177
  public notify(): void {
164
178
  if (!this.privAdapterHandle) return;
@@ -167,9 +181,22 @@ export class ModelManager<
167
181
 
168
182
  /**
169
183
  * Initializes adapter and recycler view instances, attaches them to a container element,
170
- * and applies optional configuration overrides for adapter and recyclerView.
184
+ * and applies optional configuration overrides for adapter and recyclerView (via `Object.assign`).
185
+ *
186
+ * **Requirements**
187
+ * - Call {@link setupAdapter} and {@link setupRecyclerView} beforehand to provide constructors.
188
+ * - The current `privModelList` becomes the initial dataset for the adapter.
189
+ *
190
+ * **Side effects**
191
+ * - Sets the adapter on the recycler via `recycler.setAdapter(adapter)`.
192
+ *
193
+ * @template TExtra extends object
194
+ * @param {HTMLElement} viewElement - Host element for the recycler view.
195
+ * @param {Partial<TAdapter>} [adapterOpt={}] - Shallow overrides applied to the adapter instance.
196
+ * @param {Partial<RecyclerViewContract<TAdapter>> & TExtra} [recyclerViewOpt={}] - Shallow overrides applied to the recycler instance.
197
+ * @returns {void}
198
+ * @see RecyclerViewContract#setAdapter
171
199
  */
172
-
173
200
  public load<TExtra extends object = {}>(
174
201
  viewElement: HTMLElement,
175
202
  adapterOpt: Partial<TAdapter> = {},
@@ -186,13 +213,25 @@ export class ModelManager<
186
213
  }
187
214
 
188
215
  /**
189
- * Diffs existing models against new <optgroup>/<option> data to update in place:
216
+ * Diffs existing models against new `<optgroup>`/`<option>` data to update in place:
190
217
  * reuses existing models when possible, updates positions and group membership,
191
218
  * removes stale views, and notifies adapter and listeners about updates.
219
+ *
220
+ * **Diffing strategy**
221
+ * - Groups are keyed by `label`.
222
+ * - Options are keyed by `${value}::${text}`.
223
+ * - Removed groups/options are destroyed.
224
+ * - Per-item `position` is recomputed sequentially.
225
+ *
226
+ * **Refresh semantics**
227
+ * - Computes `isUpdate`: `false` on the first run and when removals occur; `true` otherwise.
228
+ * - Calls `adapter.updateData()` and then {@link refresh} with the computed flag.
229
+ *
230
+ * @param {Array<HTMLOptGroupElement | HTMLOptionElement>} modelData - Source elements to reconcile against.
231
+ * @returns {void}
232
+ * @see Adapter#updateData
192
233
  */
193
- public update(modelData: Array<HTMLOptGroupElement | HTMLOptionElement>): void {
194
- if (!this.hasRealChanges(modelData)) return;
195
-
234
+ public updateModel(modelData: Array<HTMLOptGroupElement | HTMLOptionElement>): void {
196
235
  const oldModels = this.privModelList;
197
236
  const newModels: Array<GroupModel | OptionModel> = [];
198
237
 
@@ -220,7 +259,7 @@ export class ModelManager<
220
259
  // Label is used as key; keep original behavior.
221
260
  const hasLabelChange = existingGroup.label !== dataVset.label;
222
261
  if (hasLabelChange) {
223
- existingGroup.update(dataVset)
262
+ existingGroup.updateTarget(dataVset)
224
263
  }
225
264
 
226
265
  existingGroup.position = position;
@@ -242,7 +281,7 @@ export class ModelManager<
242
281
  const existingOption = oldOptionMap.get(key);
243
282
 
244
283
  if (existingOption) {
245
- existingOption.update(dataVset);
284
+ existingOption.updateTarget(dataVset);
246
285
  existingOption.position = position;
247
286
 
248
287
  const parentGroup = dataVset["__parentGroup"] as HTMLOptGroupElement | undefined;
@@ -282,12 +321,12 @@ export class ModelManager<
282
321
 
283
322
  oldGroupMap.forEach((removedGroup) => {
284
323
  isUpdate = false;
285
- removedGroup.remove();
324
+ removedGroup.destroy();
286
325
  });
287
326
 
288
327
  oldOptionMap.forEach((removedOption) => {
289
328
  isUpdate = false;
290
- removedOption.remove();
329
+ removedOption.destroy();
291
330
  });
292
331
 
293
332
  this.privModelList = newModels;
@@ -296,43 +335,73 @@ export class ModelManager<
296
335
  this.privAdapterHandle.updateData(this.privModelList as unknown as TModel[]);
297
336
  }
298
337
 
299
- // this.onUpdated();
300
338
  this.refresh(isUpdate);
301
339
  }
302
340
 
303
- /**
304
- * Hook invoked after the manager completes an update or refresh cycle.
305
- * Override to run side effects (e.g., layout adjustments or analytics).
306
- */
307
- public onUpdated(): void { }
308
-
309
341
  /**
310
342
  * Instructs the adapter to temporarily skip event handling (e.g., during batch updates).
311
343
  *
312
- * @param {boolean} value - True to skip events; false to restore normal behavior.
344
+ * @param {boolean} value - `true` to skip events; `false` to restore normal behavior.
345
+ * @returns {void}
313
346
  */
314
347
  public skipEvent(value: boolean): void {
315
348
  if (this.privAdapterHandle) this.privAdapterHandle.isSkipEvent = value;
316
349
  }
317
350
 
318
351
  /**
319
- * Re-renders the recycler view if present and invokes the post-refresh hook.
320
- * No-op if the recycler view is not initialized.
321
- *
322
- * @param isUpdate - Indicates if this refresh is due to an update operation.
352
+ * Re-renders the recycler view if present and invokes the lifecycle update hook.
353
+ * **No-op** if the recycler view is not initialized.
354
+ *
355
+ * @param {boolean} isUpdate - Indicates if this refresh follows an "update" operation (vs. full replace).
356
+ * @returns {void}
357
+ * @see Lifecycle#update
323
358
  */
324
359
  public refresh(isUpdate: boolean): void {
325
360
  if (!this.privRecyclerViewHandle) return;
326
361
  this.privRecyclerViewHandle.refresh(isUpdate);
327
- this.onUpdated();
362
+ this.update();
363
+ }
364
+
365
+ /**
366
+ * Releases adapter and recycler resources and clears all references.
367
+ * Transitions to `DESTROYED`; subsequent calls are idempotent.
368
+ *
369
+ * **Important**
370
+ * - Assumes handles were created via {@link load}; calling `destroy()` before `load()` may depend
371
+ * on the underlying implementations' null-tolerance.
372
+ *
373
+ * @returns {void}
374
+ */
375
+ public override destroy(): void {
376
+ if (this.is(LifecycleState.DESTROYED)) {
377
+ return;
378
+ }
379
+
380
+ this.privAdapterHandle.destroy();
381
+ this.privRecyclerViewHandle.destroy();
382
+
383
+ this.privModelList = [];
384
+ this.privAdapter = null;
385
+ this.privAdapterHandle = null;
386
+ this.privRecyclerView = null;
387
+ this.privRecyclerViewHandle = null;
388
+ this.options = null;
389
+ this.oldPosition = 0;
390
+
391
+ super.destroy();
328
392
  }
329
393
 
330
394
  /**
331
395
  * Returns handles to the current resources, including the model list,
332
396
  * adapter instance, and recycler view instance.
397
+ *
398
+ * **Note**: The returned `adapter`/`recyclerView` may be `null` at runtime if {@link load} has not been called.
399
+ *
400
+ * @returns {{ modelList: Array<MixedItem>; adapter: TAdapter; recyclerView: RecyclerViewContract<TAdapter>; }}
401
+ * The current resource references.
333
402
  */
334
403
  public getResources(): {
335
- modelList: Array<GroupModel | OptionModel>;
404
+ modelList: Array<MixedItem>;
336
405
  adapter: TAdapter;
337
406
  recyclerView: RecyclerViewContract<TAdapter>;
338
407
  } {
@@ -346,6 +415,12 @@ export class ModelManager<
346
415
  /**
347
416
  * Triggers the adapter's pre-change pipeline for a named event,
348
417
  * enabling observers to react before a change is applied.
418
+ *
419
+ * **Delegates** to {@link Adapter.changingProp}.
420
+ *
421
+ * @param {string} event_name - Logical event name (consumer-defined).
422
+ * @returns {Promise<void> | undefined} The adapter's promise, or `undefined` if the adapter is not initialized.
423
+ * @fires changing
349
424
  */
350
425
  public triggerChanging(event_name: string): Promise<void> {
351
426
  return this.privAdapterHandle?.changingProp(event_name);
@@ -354,6 +429,12 @@ export class ModelManager<
354
429
  /**
355
430
  * Triggers the adapter's post-change pipeline for a named event,
356
431
  * notifying observers after a change has been applied.
432
+ *
433
+ * **Delegates** to {@link Adapter.changeProp}.
434
+ *
435
+ * @param {string} event_name - Logical event name (consumer-defined).
436
+ * @returns {Promise<void> | undefined} The adapter's promise, or `undefined` if the adapter is not initialized.
437
+ * @fires changed
357
438
  */
358
439
  public triggerChanged(event_name: string): Promise<void> {
359
440
  return this.privAdapterHandle?.changeProp(event_name);