selective-ui 1.2.4 → 1.2.6

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 (67) hide show
  1. package/README.md +7 -0
  2. package/dist/selective-ui.css +64 -58
  3. package/dist/selective-ui.css.map +1 -1
  4. package/dist/selective-ui.esm.js +4396 -1344
  5. package/dist/selective-ui.esm.js.map +1 -1
  6. package/dist/selective-ui.esm.min.js +2 -2
  7. package/dist/selective-ui.esm.min.js.br +0 -0
  8. package/dist/selective-ui.min.css +1 -1
  9. package/dist/selective-ui.min.css.br +0 -0
  10. package/dist/selective-ui.min.js +2 -2
  11. package/dist/selective-ui.min.js.br +0 -0
  12. package/dist/selective-ui.umd.js +4401 -1345
  13. package/dist/selective-ui.umd.js.map +1 -1
  14. package/package.json +3 -3
  15. package/src/css/components/accessorybox.css +1 -1
  16. package/src/css/components/directive.css +2 -2
  17. package/src/css/components/option-handle.css +4 -4
  18. package/src/css/components/placeholder.css +1 -1
  19. package/src/css/components/popup/empty-state.css +3 -3
  20. package/src/css/components/popup/loading-state.css +3 -3
  21. package/src/css/components/popup/popup.css +5 -5
  22. package/src/css/components/searchbox.css +2 -2
  23. package/src/css/components/selectbox.css +7 -7
  24. package/src/css/views/group-view.css +8 -8
  25. package/src/css/views/option-view.css +22 -22
  26. package/src/ts/adapter/mixed-adapter.ts +248 -92
  27. package/src/ts/components/accessorybox.ts +170 -73
  28. package/src/ts/components/directive.ts +55 -26
  29. package/src/ts/components/option-handle.ts +127 -60
  30. package/src/ts/components/placeholder.ts +73 -35
  31. package/src/ts/components/popup/empty-state.ts +71 -35
  32. package/src/ts/components/popup/loading-state.ts +73 -33
  33. package/src/ts/components/popup/popup.ts +19 -39
  34. package/src/ts/components/searchbox.ts +189 -50
  35. package/src/ts/components/selectbox.ts +401 -40
  36. package/src/ts/core/base/adapter.ts +160 -79
  37. package/src/ts/core/base/fenwick.ts +147 -0
  38. package/src/ts/core/base/lifecycle.ts +118 -35
  39. package/src/ts/core/base/model.ts +94 -36
  40. package/src/ts/core/base/recyclerview.ts +0 -1
  41. package/src/ts/core/base/view.ts +54 -23
  42. package/src/ts/core/base/virtual-recyclerview.ts +365 -283
  43. package/src/ts/core/model-manager.ts +172 -92
  44. package/src/ts/core/search-controller.ts +166 -93
  45. package/src/ts/global.ts +26 -5
  46. package/src/ts/index.ts +22 -3
  47. package/src/ts/models/group-model.ts +138 -32
  48. package/src/ts/models/option-model.ts +197 -53
  49. package/src/ts/services/dataset-observer.ts +72 -10
  50. package/src/ts/services/ea-observer.ts +87 -10
  51. package/src/ts/services/effector.ts +181 -32
  52. package/src/ts/services/refresher.ts +32 -7
  53. package/src/ts/services/resize-observer.ts +136 -19
  54. package/src/ts/services/select-observer.ts +115 -50
  55. package/src/ts/types/core/base/view.type.ts +3 -3
  56. package/src/ts/types/core/base/virtual-recyclerview.type.ts +1 -1
  57. package/src/ts/types/plugins/plugin.type.ts +46 -0
  58. package/src/ts/types/utils/ievents.type.ts +6 -1
  59. package/src/ts/types/utils/istorage.type.ts +8 -4
  60. package/src/ts/types/utils/libs.type.ts +2 -2
  61. package/src/ts/types/utils/selective.type.ts +14 -1
  62. package/src/ts/utils/callback-scheduler.ts +115 -37
  63. package/src/ts/utils/ievents.ts +91 -29
  64. package/src/ts/utils/libs.ts +41 -65
  65. package/src/ts/utils/selective.ts +412 -79
  66. package/src/ts/views/group-view.ts +142 -31
  67. package/src/ts/views/option-view.ts +272 -60
@@ -1,3 +1,4 @@
1
+
1
2
  import { Libs } from "../utils/libs";
2
3
  import { Refresher } from "../services/refresher";
3
4
  import { PlaceHolder } from "./placeholder";
@@ -22,49 +23,175 @@ import type { SelectiveOptions } from "../types/utils/selective.type";
22
23
  import { IEventToken, IEventCallback } from "../types/utils/ievents.type";
23
24
  import { MixedItem } from "../types/core/base/mixed-adapter.type";
24
25
  import { BinderMap } from "../types/utils/istorage.type";
25
- import { ContainerRuntime, SelectBoxAction } from "../types/components/searchbox.type";
26
+ import { ContainerRuntime, SelectBoxAction, SelectBoxTags } from "../types/components/searchbox.type";
26
27
  import { AjaxConfig } from "../types/core/search-controller.type";
27
28
  import { Selective } from "../utils/selective";
28
29
  import { VirtualRecyclerView } from "../core/base/virtual-recyclerview";
30
+ import type { PluginContext, SelectivePlugin } from "../types/plugins/plugin.type";
29
31
 
30
32
  /**
31
- * @class
33
+ * SelectBox
34
+ *
35
+ * Root coordinator component that enhances a native `<select>` element into the library's
36
+ * DOM-driven Select UI. `SelectBox` composes and wires together the major runtime pieces:
37
+ *
38
+ * - **View layer**: {@link PlaceHolder}, {@link Directive}, {@link SearchBox}, {@link Popup}, {@link AccessoryBox}
39
+ * - **Model layer**: {@link ModelManager} with {@link MixedAdapter} resources (groups/options/navigation/visibility)
40
+ * - **Rendering layer**: {@link RecyclerView} or {@link VirtualRecyclerView} (virtual scroll)
41
+ * - **Controllers / services**: {@link SearchController}, {@link Effector}, {@link Refresher}
42
+ * - **Observers**: {@link SelectObserver} and {@link DatasetObserver} for keeping DOM/source-of-truth in sync
43
+ *
44
+ * ### Architecture / Relationships
45
+ * - The native `<select>` remains the canonical form element and is moved into the SelectBox DOM wrapper.
46
+ * - `ModelManager` owns adapter + recyclerview instances and exposes a resource model list.
47
+ * - `Popup` hosts the list UI (adapter ↔ recycler/view) and emits adapter property changes.
48
+ * - `SearchBox` emits external events (search/navigation/enter/esc), which drive adapter navigation and search.
49
+ *
50
+ * ### Lifecycle (Strict FSM)
51
+ * This class uses explicit state guards (`this.state !== ...`) to enforce a strict sequence:
52
+ * - `NEW` → {@link init} (creates subcomponents and runtime wiring) → `INITIALIZED`
53
+ * - {@link mount} (inserts wrapper and relocates `<select>` in DOM) → `MOUNTED`
54
+ * - {@link update} (resize / reactive refresh) → `UPDATED`
55
+ * - {@link destroy} (disconnect observers, destroy children, remove DOM) → `DESTROYED`
56
+ *
57
+ * Each lifecycle entry point is designed to be **idempotent/no-op** when called from an
58
+ * unexpected state.
59
+ *
60
+ * ### External vs Internal Events (Selection)
61
+ * Selection changes can be routed through two different adapter property channels:
62
+ * - `"selected"`: treated as **external** selection (user-triggered) → calls `change(..., true)`
63
+ * - `"selected_internal"`: treated as **internal** selection (non-trigger) → calls `change(..., false)`
64
+ *
65
+ * This separation allows the framework to distinguish “notify observers / emit events”
66
+ * from “silent state sync” (e.g., restoring selection, programmatic updates).
67
+ *
68
+ * ### DOM / a11y Side Effects
69
+ * - Creates a focusable `ViewPanel` and applies listbox-related ARIA attributes on open/close
70
+ * (`aria-expanded`, `aria-controls`, `aria-haspopup`, `aria-labelledby`, `aria-multiselectable`).
71
+ * - Stops `mousedown` propagation on the view panel to avoid outer click handlers capturing interaction.
72
+ *
73
+ * @extends Lifecycle
32
74
  */
33
75
  export class SelectBox extends Lifecycle {
76
+ /**
77
+ * Runtime container holding:
78
+ * - `view/tags` from {@link Libs.mountNode}
79
+ * - composed child components (placeholder, searchbox, popup, etc.)
80
+ * - runtime services/controllers and observers
81
+ *
82
+ * Declared as a `Partial` because it is progressively populated during {@link init}.
83
+ */
34
84
  public container: Partial<ContainerRuntime> = {};
35
85
 
86
+ /**
87
+ * Snapshot of the previous selection value used for rollback in `beforeChange` cancellation
88
+ * and max-selection enforcement.
89
+ *
90
+ * @internal
91
+ */
36
92
  private oldValue: unknown = null;
37
93
 
94
+ /**
95
+ * Root wrapper DOM node for the enhanced UI.
96
+ *
97
+ * Created during {@link init} via {@link Libs.mountNode}, inserted into the DOM during {@link mount},
98
+ * and removed during {@link destroy}.
99
+ */
38
100
  private node: HTMLDivElement | null = null;
39
101
 
102
+ /**
103
+ * Parsed configuration (bound from the `<select>` element via binder map).
104
+ *
105
+ * Provides feature flags (multiple/disabled/readonly/visible/virtualScroll/ajax/autoclose…),
106
+ * a11y ids (e.g. `SEID_LIST`, `SEID_HOLDER`) and user callbacks under `options.on`.
107
+ *
108
+ * @internal
109
+ */
40
110
  private options: SelectiveOptions | null = null;
41
111
 
112
+ /**
113
+ * Manager that owns model resources and bridges the Adapter ↔ RecyclerView pipeline.
114
+ *
115
+ * The configured adapter is {@link MixedAdapter}. The recyclerview implementation is chosen
116
+ * based on `options.virtualScroll` (standard {@link RecyclerView} vs {@link VirtualRecyclerView}).
117
+ *
118
+ * @internal
119
+ */
42
120
  private optionModelManager: ModelManager<MixedItem, MixedAdapter> | null = null;
43
121
 
122
+ /**
123
+ * Whether the popup/list UI is currently open.
124
+ *
125
+ * This is authoritative for the action API (`getAction().isOpen`) and open/close guards.
126
+ *
127
+ * @internal
128
+ */
44
129
  private isOpen = false;
45
130
 
131
+ /**
132
+ * Tracks whether an initial AJAX load has been performed at least once.
133
+ * Used to avoid redundant initial fetches on open.
134
+ *
135
+ * @internal
136
+ */
46
137
  private hasLoadedOnce = false;
47
138
 
139
+ /**
140
+ * Tracks whether the instance is in "pre-search" mode (a search is about to happen).
141
+ * Used as a hint to perform AJAX refresh on open.
142
+ *
143
+ * @internal
144
+ */
48
145
  private isBeforeSearch = false;
49
146
 
50
- /** Selective context (global helper) */
147
+ /**
148
+ * Tracks whether {@link deInit} has already run.
149
+ *
150
+ * This guards teardown work (including plugin lifecycle hooks) from running more than once
151
+ * when {@link deInit} is called separately before {@link destroy}.
152
+ *
153
+ * @internal
154
+ */
155
+ private hasDeInitialized = false;
156
+
157
+ /**
158
+ * Selective context (global helper / registry).
159
+ *
160
+ * Used to locate the instance wrapper via `Selective.find(...)` and to close other open instances.
161
+ */
51
162
  public Selective: Selective | null = null;
52
163
 
53
164
  /**
54
- * Initializes a SelectBox instance and, if a source <select> and Selective context are provided,
55
- * immediately calls init() to set up the enhanced UI and behavior.
165
+ * Registered plugins for this SelectBox instance.
166
+ */
167
+ private plugins: SelectivePlugin[] = [];
168
+
169
+ /**
170
+ * Cached plugin context for this SelectBox instance.
171
+ */
172
+ private pluginContext: PluginContext<SelectBoxTags> | null = null;
173
+
174
+ /**
175
+ * Creates a {@link SelectBox} bound to a native `<select>` element.
176
+ *
177
+ * When both `select` and `Selective` are provided, the instance initializes immediately
178
+ * (bind options from dataset/binder map and enters the lifecycle via {@link init}).
56
179
  *
57
- * @param {HTMLSelectElement|null} [select=null] - The native select element to enhance.
58
- * @param {any|null} [Selective=null] - The Selective framework/context used for configuration and services.
180
+ * @param select - The native select element to enhance.
181
+ * @param Selective - The Selective framework context used for registry/services.
59
182
  */
60
- public constructor(select: HTMLSelectElement | null = null, Selective: any | null = null) {
183
+ public constructor(select: HTMLSelectElement, Selective: Selective) {
61
184
  super();
62
185
  if (select && Selective) this.initialize(select, Selective);
63
186
  }
64
187
 
65
188
  /**
66
- * Gets or sets the disabled state of the SelectBox.
67
- * When set, updates CSS class and ARIA attributes to reflect the disabled state.
189
+ * Disabled state mirror for both runtime behavior and DOM/a11y representation.
190
+ *
191
+ * Side effects when set:
192
+ * - Updates `options.disabled`
193
+ * - Toggles `.disabled` on the root wrapper
194
+ * - Sets `aria-disabled` on wrapper and view panel
68
195
  */
69
196
  public get isDisabled(): boolean {
70
197
  return !!this.options?.disabled;
@@ -78,8 +205,11 @@ export class SelectBox extends Lifecycle {
78
205
  }
79
206
 
80
207
  /**
81
- * Gets or sets the read-only state of the SelectBox.
82
- * When set, toggles the "readonly" CSS class to prevent user interaction.
208
+ * Read-only state mirror.
209
+ *
210
+ * Side effects when set:
211
+ * - Updates `options.readonly`
212
+ * - Toggles `.readonly` on the root wrapper to prevent user interaction in UI layer
83
213
  */
84
214
  public get isReadOnly(): boolean {
85
215
  return !!this.options?.readonly;
@@ -91,8 +221,11 @@ export class SelectBox extends Lifecycle {
91
221
  }
92
222
 
93
223
  /**
94
- * Gets or sets the visibility state of the SelectBox.
95
- * When set, toggles the "invisible" CSS class to show or hide the component.
224
+ * Visibility state mirror.
225
+ *
226
+ * Side effects when set:
227
+ * - Updates `options.visible`
228
+ * - Toggles `.invisible` class on the root wrapper
96
229
  */
97
230
  public get isVisible(): boolean {
98
231
  return !!this.options?.visible;
@@ -104,18 +237,47 @@ export class SelectBox extends Lifecycle {
104
237
  }
105
238
 
106
239
  /**
107
- * Wrapper method to initialize with select element
240
+ * Binds configuration and Selective context, then enters lifecycle initialization.
241
+ *
242
+ * Sources configuration from the select element binder map:
243
+ * - {@link Libs.getBinderMap} → {@link BinderMap.options} → {@link SelectiveOptions}
244
+ *
245
+ * @param select - Native select element being enhanced.
246
+ * @param Selective - Selective runtime context.
247
+ * @internal
108
248
  */
109
- private initialize(select: HTMLSelectElement, Selective: any): void {
110
- const bindedMap = Libs.getBinderMap(select) as BinderMap;
111
- this.options = bindedMap.options as SelectiveOptions;
249
+ private initialize(select: HTMLSelectElement, Selective: Selective): void {
250
+ const bindedMap = Libs.getBinderMap<BinderMap>(select);
251
+ this.options = bindedMap.options;
112
252
  this.Selective = Selective;
113
253
 
114
254
  this.init(select);
115
255
  }
116
256
 
117
257
  /**
118
- * Override lifecycle init - Creates all components and DOM structure
258
+ * Lifecycle: `init` (composition / wiring stage).
259
+ *
260
+ * Strict FSM:
261
+ * - No-ops unless `state === NEW`.
262
+ *
263
+ * Responsibilities:
264
+ * - Instantiate view subcomponents (placeholder/directive/searchbox/accessory/popup).
265
+ * - Create and mount the container DOM structure (but does not insert into document yet).
266
+ * - Configure {@link ModelManager} with {@link MixedAdapter} and a RecyclerView implementation
267
+ * ({@link VirtualRecyclerView} when `options.virtualScroll`).
268
+ * - Create initial model resources by parsing the source `<select>`.
269
+ * - Wire controller/service flows:
270
+ * - search events → {@link SearchController} → adapter updates → popup resize/highlight resets
271
+ * - adapter selection changes → action API {@link SelectBoxAction.change} with trigger rules
272
+ * - Connect observers for two-way synchronization:
273
+ * - {@link SelectObserver} for option changes in `<select>`
274
+ * - {@link DatasetObserver} for runtime flags (disabled/readonly/visible) from dataset
275
+ *
276
+ * DOM/a11y:
277
+ * - Ensures placeholder node has an id for `aria-labelledby` usage.
278
+ * - Adds a keydown handler on `ViewPanel` to open on Enter/Space/ArrowDown.
279
+ *
280
+ * @param select - Native select element used as source of truth for options/value.
119
281
  */
120
282
  public init(select?: HTMLSelectElement): void {
121
283
  if (this.state !== LifecycleState.NEW) return;
@@ -138,15 +300,15 @@ export class SelectBox extends Lifecycle {
138
300
  // ensure placeholder has id for aria-labelledby usage
139
301
  if (placeholder.node) placeholder.node.id = String(options.SEID_HOLDER ?? "");
140
302
 
141
- const container = Libs.mountNode(
303
+ const container = Libs.mountNode<ContainerRuntime>(
142
304
  {
143
305
  Container: {
144
- tag: { node: "div", classList: "selective-ui-MAIN" },
306
+ tag: { node: "div", classList: "seui-MAIN" },
145
307
  child: {
146
308
  ViewPanel: {
147
309
  tag: {
148
310
  node: "div",
149
- classList: "selective-ui-view",
311
+ classList: "seui-view",
150
312
  tabIndex: 0,
151
313
  onkeydown: (e: KeyboardEvent) => {
152
314
  if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
@@ -165,7 +327,7 @@ export class SelectBox extends Lifecycle {
165
327
  },
166
328
  },
167
329
  null
168
- ) as unknown as ContainerRuntime;
330
+ );
169
331
 
170
332
  this.container = container;
171
333
  this.node = container.view as HTMLDivElement;
@@ -190,9 +352,9 @@ export class SelectBox extends Lifecycle {
190
352
  }
191
353
  optionModelManager.createModelResources(Libs.parseSelectToArray(select));
192
354
 
193
- optionModelManager.onUpdated = () => {
355
+ optionModelManager.on("onUpdate", () => {
194
356
  container.popup?.triggerResize?.();
195
- };
357
+ });
196
358
 
197
359
  this.optionModelManager = optionModelManager;
198
360
 
@@ -217,6 +379,21 @@ export class SelectBox extends Lifecycle {
217
379
  this.setupEventHandlers(select, container, options, searchController, searchbox);
218
380
  this.setupObservers(selectObserver, datasetObserver, select, optionModelManager);
219
381
 
382
+ this.plugins = this.Selective?.getPlugins?.() ?? [];
383
+ if (this.plugins.length) {
384
+ const resources = optionModelManager.getResources();
385
+ const pluginContext: PluginContext<SelectBoxTags> = {
386
+ selectBox: this,
387
+ options,
388
+ adapter: resources.adapter,
389
+ recycler: resources.recyclerView,
390
+ viewTags: container.tags,
391
+ actions: this.getAction(),
392
+ };
393
+ this.pluginContext = pluginContext;
394
+ this.runPluginHook("init", (plugin) => plugin.init?.(pluginContext));
395
+ }
396
+
220
397
  // Initial states
221
398
  this.isDisabled = Libs.string2Boolean(options.disabled);
222
399
  this.isReadOnly = Libs.string2Boolean(options.readonly);
@@ -226,14 +403,24 @@ export class SelectBox extends Lifecycle {
226
403
  }
227
404
 
228
405
  /**
229
- * Override lifecycle mount - Mounts component into DOM
406
+ * Lifecycle: `mount` (DOM insertion stage).
407
+ *
408
+ * Strict FSM:
409
+ * - No-ops unless `state === INITIALIZED`.
410
+ *
411
+ * DOM operations:
412
+ * - Inserts the SelectBox wrapper before the original `<select>`.
413
+ * - Moves the `<select>` inside the wrapper (before `ViewPanel`) to preserve form behavior.
414
+ * - Adds a `mousedown` handler to `ViewPanel` to contain interactions and prevent outer handlers.
415
+ * - Applies initial sizing (`Refresher.resizeBox`) and marks the select as initialized (`.init`).
416
+ * - Applies an initial "mask" refresh via `change(null, false)` without emitting external triggers.
230
417
  */
231
418
  public mount(): void {
232
419
  if (this.state !== LifecycleState.INITIALIZED) return;
233
420
  if (!this.node || !this.container.targetElement) return;
234
421
 
235
422
  const select = this.container.targetElement;
236
- const container = this.container as ContainerRuntime;
423
+ const container = this.container;
237
424
 
238
425
  // Mount into DOM: wrapper before select, then move select inside
239
426
  select.parentNode?.insertBefore(this.node, select);
@@ -255,7 +442,18 @@ export class SelectBox extends Lifecycle {
255
442
  }
256
443
 
257
444
  /**
258
- * Override lifecycle update - Called when data/state changes
445
+ * Lifecycle: `update` (reactive refresh stage).
446
+ *
447
+ * Strict FSM:
448
+ * - No-ops unless `state === MOUNTED`.
449
+ *
450
+ * Behavior:
451
+ * - Triggers popup resize recalculation to keep layout consistent with content changes
452
+ * (e.g. filtering results, collapses/expands, accessory changes).
453
+ *
454
+ * Note:
455
+ * - Actual data mutations are driven by adapter/model updates and action API methods,
456
+ * not by this method directly.
259
457
  */
260
458
  public update(): void {
261
459
  if (this.state !== LifecycleState.MOUNTED) return;
@@ -267,7 +465,24 @@ export class SelectBox extends Lifecycle {
267
465
  }
268
466
 
269
467
  /**
270
- * Setup event handlers (extracted from init for clarity)
468
+ * Wires event handlers between UI components, controller, and adapter.
469
+ *
470
+ * Key flows:
471
+ * - SearchBox input → SearchController.search/clear → Popup resize + adapter highlight reset
472
+ * - SearchBox navigation/enter/esc → MixedAdapter.navigate/selectHighlighted + close + focus restore
473
+ * - Adapter highlight changes → SearchBox `aria-activedescendant`
474
+ * - Adapter collapsed changes → Popup resize
475
+ *
476
+ * Trigger semantics:
477
+ * - The `isTrigger` boolean from SearchBox is used to distinguish user-driven vs programmatic clears.
478
+ * - AJAX searches optionally show/hide loading UI and respect `delaysearchtime`.
479
+ *
480
+ * @param select - The enhanced native select element.
481
+ * @param container - The assembled runtime container.
482
+ * @param options - Bound configuration flags and callbacks.
483
+ * @param searchController - Controller responsible for local/AJAX searches and pagination.
484
+ * @param searchbox - Search input component emitting search/navigation intents.
485
+ * @internal
271
486
  */
272
487
  private setupEventHandlers(
273
488
  select: HTMLSelectElement,
@@ -276,7 +491,7 @@ export class SelectBox extends Lifecycle {
276
491
  searchController: SearchController,
277
492
  searchbox: SearchBox
278
493
  ): void {
279
- const optionAdapter = container.popup!.optionAdapter as MixedAdapter;
494
+ const optionAdapter = container.popup!.optionAdapter;
280
495
  let hightlightTimer: ReturnType<typeof setTimeout> | null = null;
281
496
 
282
497
  const searchHandle = (keyword: string, isTrigger: boolean) => {
@@ -352,12 +567,28 @@ export class SelectBox extends Lifecycle {
352
567
 
353
568
  // AJAX setup (if provided)
354
569
  if (options.ajax) {
570
+ if (options.ajax?.keepSelected == undefined) {
571
+ options.ajax.keepSelected = options.keepSelected;
572
+ }
355
573
  searchController.setAjax(options.ajax);
356
574
  }
357
575
  }
358
576
 
359
577
  /**
360
- * Setup observers (extracted from init for clarity)
578
+ * Connects and wires observers that synchronize the enhanced UI with the source `<select>`
579
+ * element and its dataset-based runtime flags.
580
+ *
581
+ * - {@link SelectObserver}:
582
+ * - On change, re-parses the select into resources and refreshes the selection mask.
583
+ * - {@link DatasetObserver}:
584
+ * - On change, mirrors dataset flags into runtime properties:
585
+ * `disabled` / `readonly` / `visible`
586
+ *
587
+ * @param selectObserver - Observer tracking select option/value mutations.
588
+ * @param datasetObserver - Observer tracking dataset attribute changes.
589
+ * @param select - The enhanced native select element.
590
+ * @param optionModelManager - Model manager to update from parsed select.
591
+ * @internal
361
592
  */
362
593
  private setupObservers(
363
594
  selectObserver: SelectObserver,
@@ -367,7 +598,7 @@ export class SelectBox extends Lifecycle {
367
598
  ): void {
368
599
  selectObserver.connect();
369
600
  selectObserver.onChanged = (sel) => {
370
- optionModelManager.update(Libs.parseSelectToArray(sel));
601
+ optionModelManager.updateModel(Libs.parseSelectToArray(sel));
371
602
  this.getAction()?.refreshMask();
372
603
  };
373
604
 
@@ -386,18 +617,44 @@ export class SelectBox extends Lifecycle {
386
617
  }
387
618
 
388
619
  /**
389
- * Disconnects observers associated with the SelectBox instance.
620
+ * Disconnects observers associated with this instance.
621
+ *
622
+ * This is used during {@link destroy} to ensure external DOM observers are stopped,
623
+ * preventing memory leaks and unintended background updates.
390
624
  */
391
625
  public deInit(): void {
626
+ if (this.hasDeInitialized) {
627
+ return;
628
+ }
629
+
392
630
  const c: any = this.container ?? {};
393
631
  const { selectObserver, datasetObserver } = c;
394
632
 
633
+ if (this.plugins.length) {
634
+ this.runPluginHook("destroy", (plugin) => plugin.destroy?.());
635
+ }
636
+ this.plugins = [];
637
+ this.pluginContext = null;
638
+
395
639
  if (selectObserver?.disconnect) selectObserver.disconnect();
396
640
  if (datasetObserver?.disconnect) datasetObserver.disconnect();
641
+
642
+ this.hasDeInitialized = true;
397
643
  }
398
644
 
399
645
  /**
400
- * Override lifecycle destroy - Complete cleanup
646
+ * Lifecycle: `destroy` (teardown stage).
647
+ *
648
+ * Strict FSM / idempotency:
649
+ * - No-ops when already in {@link LifecycleState.DESTROYED}.
650
+ *
651
+ * Responsibilities:
652
+ * - Disconnect observers.
653
+ * - Destroy composed child components/controllers.
654
+ * - Remove wrapper DOM from the document.
655
+ * - Clear references to enable garbage collection.
656
+ *
657
+ * @override
401
658
  */
402
659
  public override destroy(): void {
403
660
  if (this.is(LifecycleState.DESTROYED)) {
@@ -415,6 +672,7 @@ export class SelectBox extends Lifecycle {
415
672
  container.accessorybox.destroy();
416
673
  container.placeholder.destroy();
417
674
  container.searchbox.destroy();
675
+ this.optionModelManager.destroy();
418
676
 
419
677
  // Remove from DOM
420
678
  this.node?.remove();
@@ -435,7 +693,33 @@ export class SelectBox extends Lifecycle {
435
693
  }
436
694
 
437
695
  /**
438
- * Returns an action API for controlling the SelectBox instance.
696
+ * Builds and returns an imperative action API for controlling this SelectBox instance.
697
+ *
698
+ * The returned object is a "facade" used by external consumers (and internal wiring) to:
699
+ * - read/write selection values (`value`, `valueArray`, `setValue`, `selectAll`, `deSelectAll`)
700
+ * - control popup visibility (`open`, `close`, `toggle`)
701
+ * - refresh mask/placeholder (`refreshMask`)
702
+ * - attach event callbacks (`on`)
703
+ * - configure AJAX (`ajax`, `loadAjax`)
704
+ *
705
+ * ### Triggering contract (external vs internal)
706
+ * Many methods accept a `trigger`/`canTrigger` boolean which controls whether:
707
+ * - `beforeChange` / `change` callbacks are invoked via {@link iEvents.callEvent}
708
+ * - native DOM `"change"` is fired on the underlying select
709
+ *
710
+ * This mirrors the library convention of distinguishing user-visible change events from
711
+ * internal/non-trigger state synchronization.
712
+ *
713
+ * ### Side effects
714
+ * - Mutates `OptionModel.selectedNonTrigger` flags to update selection.
715
+ * - Writes to the native select value for single-select mode.
716
+ * - Updates UI mask and accessory box, and requests popup resizing where needed.
717
+ * - Applies a11y attributes to `ViewPanel` on open/close.
718
+ *
719
+ * No-ops:
720
+ * - Returns `null` when the binder map is missing for the current target element.
721
+ *
722
+ * @returns An action facade for controlling this instance, or `null` if not bound.
439
723
  */
440
724
  public getAction(): SelectBoxAction | null {
441
725
  const container = this.container;
@@ -444,7 +728,7 @@ export class SelectBox extends Lifecycle {
444
728
  return this.Selective.find(container.targetElement);
445
729
  };
446
730
 
447
- const bindedMap = Libs.getBinderMap(container.targetElement) as BinderMap | null;
731
+ const bindedMap = Libs.getBinderMap<BinderMap>(container.targetElement);
448
732
  if (!bindedMap) return null;
449
733
 
450
734
  const bindedOptions = bindedMap.options;
@@ -702,6 +986,9 @@ export class SelectBox extends Lifecycle {
702
986
  if (bindedOptions.multiple) ViewPanel.setAttribute("aria-multiselectable", "true");
703
987
 
704
988
  iEvents.callEvent([getInstance()], ...bindedOptions.on.show);
989
+ if (superThis.pluginContext) {
990
+ superThis.runPluginHook("onOpen", (plugin) => plugin.onOpen?.(superThis.pluginContext));
991
+ }
705
992
  return;
706
993
  },
707
994
 
@@ -722,6 +1009,9 @@ export class SelectBox extends Lifecycle {
722
1009
  container.tags.ViewPanel.setAttribute("aria-expanded", "false");
723
1010
 
724
1011
  iEvents.callEvent([getInstance()], ...bindedOptions.on.close);
1012
+ if (superThis.pluginContext) {
1013
+ superThis.runPluginHook("onClose", (plugin) => plugin.onClose?.(superThis.pluginContext));
1014
+ }
725
1015
  return;
726
1016
  },
727
1017
 
@@ -764,6 +1054,13 @@ export class SelectBox extends Lifecycle {
764
1054
  if (superThis.is(LifecycleState.MOUNTED)) {
765
1055
  superThis.update();
766
1056
  }
1057
+
1058
+ if (superThis.pluginContext && superThis.optionModelManager) {
1059
+ const resources = superThis.optionModelManager.getResources();
1060
+ superThis.runPluginHook("onChange", (plugin) =>
1061
+ plugin.onChange?.(this.value, resources.modelList, resources.adapter, superThis.pluginContext)
1062
+ );
1063
+ }
767
1064
  },
768
1065
 
769
1066
  refreshMask() {
@@ -785,6 +1082,9 @@ export class SelectBox extends Lifecycle {
785
1082
  },
786
1083
 
787
1084
  ajax(_evtToken: IEventCallback, obj: AjaxConfig) {
1085
+ if (obj.keepSelected == undefined) {
1086
+ obj.keepSelected = superThis.options.keepSelected;
1087
+ }
788
1088
  container.searchController.setAjax(obj);
789
1089
  },
790
1090
 
@@ -822,7 +1122,29 @@ export class SelectBox extends Lifecycle {
822
1122
  }
823
1123
 
824
1124
  /**
825
- * Creates a property on the given object with custom getter and setter behavior.
1125
+ * Defines a mirrored facade property on an arbitrary object.
1126
+ *
1127
+ * This helper is used when building the {@link SelectBoxAction} facade to expose
1128
+ * `disabled` / `readonly` / `visible` as ergonomic properties while keeping them
1129
+ * synchronized with the underlying {@link SelectBox} runtime state.
1130
+ *
1131
+ * ### Behavior
1132
+ * - Getter proxies the current runtime value from `this[privateProp]`.
1133
+ * - Setter coerces the incoming value to boolean and writes it to `this[privateProp]`.
1134
+ * - Additionally reflects the value onto `targetElement.dataset[prop]` when available,
1135
+ * allowing external dataset observers (and DOM tooling) to observe state changes.
1136
+ *
1137
+ * ### Side effects
1138
+ * - Mutates the action facade object via `Object.defineProperty`.
1139
+ * - Mutates DOM dataset on the underlying `<select>` element (if present).
1140
+ *
1141
+ * No-ops:
1142
+ * - Dataset reflection is skipped when `container.targetElement.dataset` is unavailable.
1143
+ *
1144
+ * @param obj - The facade object to define the property on.
1145
+ * @param prop - The public facade property name (`disabled` | `readonly` | `visible`).
1146
+ * @param privateProp - The backing SelectBox property name (`isDisabled` | `isReadOnly` | `isVisible`).
1147
+ * @internal
826
1148
  */
827
1149
  private createSymProp(
828
1150
  obj: Record<string, any>,
@@ -847,7 +1169,25 @@ export class SelectBox extends Lifecycle {
847
1169
  }
848
1170
 
849
1171
  /**
850
- * Flattens and returns all option models from the current resources.
1172
+ * Returns a flat list of {@link OptionModel} items from current model resources.
1173
+ *
1174
+ * The underlying resource list may contain a mix of:
1175
+ * - {@link OptionModel} (standalone options)
1176
+ * - {@link GroupModel} (group headers with nested `items`)
1177
+ *
1178
+ * This method flattens the structure into a single array of options, optionally
1179
+ * filtered by the *current* selection state.
1180
+ *
1181
+ * ### Filtering
1182
+ * - When `isSelected` is `true` or `false`, filters by `OptionModel.selected`.
1183
+ * - When `isSelected` is `null`, returns all available options.
1184
+ *
1185
+ * No-ops:
1186
+ * - Returns an empty array if the {@link optionModelManager} is not available.
1187
+ *
1188
+ * @param isSelected - Optional selection filter (`true` | `false` | `null`). Defaults to `null`.
1189
+ * @returns A flat array of option models (possibly filtered).
1190
+ * @internal
851
1191
  */
852
1192
  private getModelOption(isSelected: boolean | null = null): OptionModel[] {
853
1193
  if (!this.optionModelManager) return [];
@@ -855,7 +1195,7 @@ export class SelectBox extends Lifecycle {
855
1195
  const { modelList } = this.optionModelManager.getResources();
856
1196
  const flatOptions: OptionModel[] = [];
857
1197
 
858
- for (const m of modelList as MixedItem[]) {
1198
+ for (const m of modelList) {
859
1199
  if (m instanceof OptionModel) {
860
1200
  flatOptions.push(m);
861
1201
  } else if (m instanceof GroupModel) {
@@ -869,4 +1209,25 @@ export class SelectBox extends Lifecycle {
869
1209
 
870
1210
  return flatOptions;
871
1211
  }
872
- }
1212
+
1213
+ /**
1214
+ * Safely runs a hook across all registered plugins.
1215
+ *
1216
+ * Any plugin failure is isolated to prevent breaking the current flow.
1217
+ *
1218
+ * @param hook - Hook name for logging context.
1219
+ * @param runner - Hook invocation handler.
1220
+ * @internal
1221
+ */
1222
+ private runPluginHook(hook: string, runner: (plugin: SelectivePlugin) => void): void {
1223
+ if (!this.plugins.length) return;
1224
+
1225
+ this.plugins.forEach((plugin) => {
1226
+ try {
1227
+ runner(plugin);
1228
+ } catch (error) {
1229
+ console.error(`Plugin "${plugin.id}" ${hook} error:`, error);
1230
+ }
1231
+ });
1232
+ }
1233
+ }