selective-ui 1.2.2 → 1.2.4

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 (53) hide show
  1. package/dist/selective-ui.css.map +1 -1
  2. package/dist/selective-ui.esm.js +2178 -695
  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 +2179 -696
  9. package/dist/selective-ui.umd.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/ts/adapter/mixed-adapter.ts +167 -80
  12. package/src/ts/components/accessorybox.ts +155 -34
  13. package/src/ts/components/directive.ts +62 -11
  14. package/src/ts/components/option-handle.ts +124 -28
  15. package/src/ts/components/placeholder.ts +73 -16
  16. package/src/ts/components/popup/empty-state.ts +126 -0
  17. package/src/ts/components/popup/loading-state.ts +120 -0
  18. package/src/ts/components/{popup.ts → popup/popup.ts} +169 -72
  19. package/src/ts/components/searchbox.ts +82 -16
  20. package/src/ts/components/selectbox.ts +207 -115
  21. package/src/ts/core/base/adapter.ts +121 -55
  22. package/src/ts/core/base/lifecycle.ts +175 -0
  23. package/src/ts/core/base/model.ts +63 -32
  24. package/src/ts/core/base/recyclerview.ts +56 -18
  25. package/src/ts/core/base/view.ts +56 -19
  26. package/src/ts/core/base/virtual-recyclerview.ts +322 -128
  27. package/src/ts/core/model-manager.ts +18 -11
  28. package/src/ts/core/search-controller.ts +148 -25
  29. package/src/ts/global.ts +5 -5
  30. package/src/ts/index.ts +5 -5
  31. package/src/ts/models/group-model.ts +27 -6
  32. package/src/ts/models/option-model.ts +29 -6
  33. package/src/ts/services/ea-observer.ts +6 -6
  34. package/src/ts/services/effector.ts +2 -2
  35. package/src/ts/services/select-observer.ts +1 -8
  36. package/src/ts/types/components/searchbox.type.ts +1 -1
  37. package/src/ts/types/core/base/adapter.type.ts +2 -1
  38. package/src/ts/types/core/base/lifecycle.type.ts +62 -0
  39. package/src/ts/types/core/base/model.type.ts +3 -1
  40. package/src/ts/types/core/base/recyclerview.type.ts +2 -8
  41. package/src/ts/types/core/base/view.type.ts +36 -24
  42. package/src/ts/utils/callback-scheduler.ts +38 -18
  43. package/src/ts/utils/istorage.ts +1 -1
  44. package/src/ts/utils/selective.ts +153 -36
  45. package/src/ts/views/group-view.ts +59 -21
  46. package/src/ts/views/option-view.ts +137 -68
  47. package/src/ts/components/empty-state.ts +0 -68
  48. package/src/ts/components/loading-state.ts +0 -66
  49. /package/src/css/components/{empty-state.css → popup/empty-state.css} +0 -0
  50. /package/src/css/components/{loading-state.css → popup/loading-state.css} +0 -0
  51. /package/src/css/components/{popup.css → popup/popup.css} +0 -0
  52. /package/src/css/{components/optgroup.css → views/group-view.css} +0 -0
  53. /package/src/css/{components/option.css → views/option-view.css} +0 -0
@@ -29,6 +29,8 @@ export class ModelManager<
29
29
 
30
30
  private options: SelectiveOptions = null;
31
31
 
32
+ private oldPosition = 0;
33
+
32
34
  /**
33
35
  * Constructs a ModelManager with configuration options used by created models and components.
34
36
  *
@@ -142,13 +144,13 @@ export class ModelManager<
142
144
  *
143
145
  * @param {Array<HTMLOptGroupElement|HTMLOptionElement>} modelData - New source elements to rebuild models from.
144
146
  */
145
- public replace(modelData: Array<HTMLOptGroupElement | HTMLOptionElement>): void {
147
+ public async replace(modelData: Array<HTMLOptGroupElement | HTMLOptionElement>): Promise<void> {
146
148
  this.lastFingerprint = null;
147
149
  this.createModelResources(modelData);
148
150
 
149
151
  if (this.privAdapterHandle) {
150
152
  // Adapter expects TModel[], but this manager's list is GroupModel|OptionModel.
151
- this.privAdapterHandle.syncFromSource(this.privModelList as unknown as TModel[]);
153
+ await this.privAdapterHandle.syncFromSource(this.privModelList as unknown as TModel[]);
152
154
  }
153
155
 
154
156
  this.refresh(false);
@@ -218,7 +220,7 @@ export class ModelManager<
218
220
  // Label is used as key; keep original behavior.
219
221
  const hasLabelChange = existingGroup.label !== dataVset.label;
220
222
  if (hasLabelChange) {
221
- existingGroup.update(dataVset)
223
+ existingGroup.updateTarget(dataVset)
222
224
  }
223
225
 
224
226
  existingGroup.position = position;
@@ -240,7 +242,7 @@ export class ModelManager<
240
242
  const existingOption = oldOptionMap.get(key);
241
243
 
242
244
  if (existingOption) {
243
- existingOption.update(dataVset);
245
+ existingOption.updateTarget(dataVset);
244
246
  existingOption.position = position;
245
247
 
246
248
  const parentGroup = dataVset["__parentGroup"] as HTMLOptGroupElement | undefined;
@@ -273,14 +275,19 @@ export class ModelManager<
273
275
  });
274
276
 
275
277
  let isUpdate = true;
278
+ if (this.oldPosition == 0) {
279
+ isUpdate = false;
280
+ }
281
+ this.oldPosition = position;
282
+
276
283
  oldGroupMap.forEach((removedGroup) => {
277
284
  isUpdate = false;
278
- removedGroup.remove();
285
+ removedGroup.destroy();
279
286
  });
280
287
 
281
288
  oldOptionMap.forEach((removedOption) => {
282
289
  isUpdate = false;
283
- removedOption.remove();
290
+ removedOption.destroy();
284
291
  });
285
292
 
286
293
  this.privModelList = newModels;
@@ -289,7 +296,7 @@ export class ModelManager<
289
296
  this.privAdapterHandle.updateData(this.privModelList as unknown as TModel[]);
290
297
  }
291
298
 
292
- this.onUpdated();
299
+ // this.onUpdated();
293
300
  this.refresh(isUpdate);
294
301
  }
295
302
 
@@ -340,15 +347,15 @@ export class ModelManager<
340
347
  * Triggers the adapter's pre-change pipeline for a named event,
341
348
  * enabling observers to react before a change is applied.
342
349
  */
343
- public triggerChanging(event_name: string): void {
344
- this.privAdapterHandle?.changingProp(event_name);
350
+ public triggerChanging(event_name: string): Promise<void> {
351
+ return this.privAdapterHandle?.changingProp(event_name);
345
352
  }
346
353
 
347
354
  /**
348
355
  * Triggers the adapter's post-change pipeline for a named event,
349
356
  * notifying observers after a change has been applied.
350
357
  */
351
- public triggerChanged(event_name: string): void {
352
- this.privAdapterHandle?.changeProp(event_name);
358
+ public triggerChanged(event_name: string): Promise<void> {
359
+ return this.privAdapterHandle?.changeProp(event_name);
353
360
  }
354
361
  }
@@ -1,26 +1,51 @@
1
-
2
- import { Popup } from "../components/popup";
1
+ import { Popup } from "../components/popup/popup";
3
2
  import { SelectBox } from "../components/selectbox";
4
3
  import { GroupModel } from "../models/group-model";
5
4
  import { OptionModel } from "../models/option-model";
5
+ import { LifecycleState } from "../types/core/base/lifecycle.type";
6
6
  import { MixedItem } from "../types/core/base/mixed-adapter.type";
7
7
  import { AjaxConfig, NormalizedAjaxItem, PaginationState, ParseResponseResult } from "../types/core/search-controller.type";
8
8
  import { Libs } from "../utils/libs";
9
+ import { Lifecycle } from "./base/lifecycle";
9
10
  import { ModelManager } from "./model-manager";
10
11
 
11
- export class SearchController {
12
+ /**
13
+ * Controller responsible for orchestrating search behavior across the Select UI.
14
+ *
15
+ * Responsibilities:
16
+ * - Manage local (in-memory) filtering and remote (AJAX) searching
17
+ * - Normalize heterogeneous server responses into a common structure
18
+ * - Maintain pagination state (page, totals, loading, hasMore)
19
+ * - Apply remote results back to the underlying <select> element
20
+ * - Coordinate UI updates via Popup (loading indicator, resize, empty/not-found states)
21
+ *
22
+ * Lifecycle:
23
+ * - Constructed with references to the native <select>, ModelManager, and SelectBox
24
+ * - `init()` runs immediately via `initialize()`
25
+ * - Methods `search()`, `loadMore()`, `clear()` are invoked by higher-level components
26
+ *
27
+ * @extends Lifecycle
28
+ */
29
+ export class SearchController extends Lifecycle {
30
+ /** Backing native <select> element providing context and initial options. */
12
31
  private select: HTMLSelectElement;
13
32
 
33
+ /** Model manager providing access to model resources (items, adapter, recycler). */
14
34
  private modelManager: ModelManager<MixedItem, any>;
15
35
 
36
+ /** Current AJAX configuration; when absent, local search is used. */
16
37
  private ajaxConfig: AjaxConfig | null = null;
17
38
 
39
+ /** Used to cancel in-flight AJAX requests when a new search starts. */
18
40
  private abortController: AbortController | null = null;
19
41
 
42
+ /** Popup instance to reflect UI states (loading, empty/not-found), and sizing. */
20
43
  private popup: Popup | null = null;
21
44
 
45
+ /** SelectBox handle (used by custom data builders). */
22
46
  private selectBox: SelectBox = null;
23
47
 
48
+ /** Current pagination and loading state for remote searches. */
24
49
  private paginationState: PaginationState = {
25
50
  currentPage: 0,
26
51
  totalPages: 1,
@@ -34,29 +59,49 @@ export class SearchController {
34
59
  * Initializes the SearchController with a source <select> element and a ModelManager
35
60
  * to manage option models and search results.
36
61
  *
37
- * @param {HTMLSelectElement} selectElement - The native select element that provides context and data source.
38
- * @param {ModelManager<MixedItem, any>} modelManager - Manager responsible for models and rendering updates.
39
- * @param {SelectBox} selectBox - SelectBox handle.
62
+ * @param selectElement - The native select element that provides context and data source.
63
+ * @param modelManager - Manager responsible for models and rendering updates.
64
+ * @param selectBox - SelectBox handle.
40
65
  */
41
66
  public constructor(selectElement: HTMLSelectElement, modelManager: ModelManager<MixedItem, any>, selectBox: SelectBox) {
67
+ super();
68
+ this.initialize(selectElement, modelManager, selectBox);
69
+ }
70
+
71
+ /**
72
+ * Internal initializer that captures dependencies and starts the lifecycle.
73
+ *
74
+ * @param selectElement - The native select element that provides context and data source.
75
+ * @param modelManager - Manager responsible for models and rendering updates.
76
+ * @param selectBox - SelectBox handle.
77
+ */
78
+ private initialize(selectElement: HTMLSelectElement, modelManager: ModelManager<MixedItem, any>, selectBox: SelectBox): void {
42
79
  this.select = selectElement;
43
80
  this.modelManager = modelManager;
44
81
  this.selectBox = selectBox;
82
+
83
+ this.init();
45
84
  }
46
85
 
47
86
  /**
48
87
  * Indicates whether AJAX-based search is configured.
49
88
  *
50
- * @returns {boolean} - True if AJAX config is present; false otherwise.
89
+ * @returns True if AJAX config is present; false otherwise.
51
90
  */
52
91
  public isAjax(): boolean {
53
92
  return !!this.ajaxConfig;
54
93
  }
55
94
 
56
95
  /**
57
- * Load specific options by their values from server
58
- * @param {string|string[]} values - Values to load
59
- * @returns {Promise<{success: boolean, items: Array, message?: string}>}
96
+ * Loads specific options by their values from the server.
97
+ *
98
+ * Behavior:
99
+ * - Uses `ajaxConfig.dataByValues` if provided; otherwise builds a default payload.
100
+ * - Supports GET/POST according to `ajaxConfig.method` (defaults to GET).
101
+ * - Normalizes the response via `parseResponse()` and returns normalized items.
102
+ *
103
+ * @param values - Value or list of values to load.
104
+ * @returns Promise resolving with `{ success, items, message? }`.
60
105
  */
61
106
  async loadByValues(values: string | string[]): Promise<{ success: boolean; items: NormalizedAjaxItem[]; message?: string }> {
62
107
  if (!this.ajaxConfig) {
@@ -76,7 +121,9 @@ export class SearchController {
76
121
  payload = {
77
122
  values: valuesArray.join(","),
78
123
  load_by_values: "1",
79
- ...(typeof cfg.data === "function" ? cfg.data.bind(this.selectBox.Selective.find(this.selectBox.container.targetElement))("", 0) : cfg.data ?? {}),
124
+ ...(typeof cfg.data === "function"
125
+ ? cfg.data.bind(this.selectBox.Selective.find(this.selectBox.container.targetElement))("", 0)
126
+ : cfg.data ?? {}),
80
127
  };
81
128
  }
82
129
 
@@ -100,6 +147,8 @@ export class SearchController {
100
147
  const data = await response.json();
101
148
  const result = this.parseResponse(data);
102
149
 
150
+ this.update();
151
+
103
152
  return { success: true, items: result.items };
104
153
  } catch (error: any) {
105
154
  console.error("Load by values error:", error);
@@ -108,9 +157,10 @@ export class SearchController {
108
157
  }
109
158
 
110
159
  /**
111
- * Check if values exist in current options
112
- * @param {string[]} values - Values to check
113
- * @returns {{existing: string[], missing: string[]}}
160
+ * Checks whether the provided values already exist in the current <select> options.
161
+ *
162
+ * @param values - Values to check for presence.
163
+ * @returns Partitioned result: `{ existing, missing }`.
114
164
  */
115
165
  public checkMissingValues(values: string[]): { existing: string[]; missing: string[] } {
116
166
  const allOptions = Array.from(this.select.options);
@@ -125,7 +175,7 @@ export class SearchController {
125
175
  /**
126
176
  * Configures AJAX settings used for remote searching and pagination.
127
177
  *
128
- * @param {object} config - AJAX configuration object (e.g., endpoint, headers, query params).
178
+ * @param config - AJAX configuration (endpoint, method, data builders, etc.).
129
179
  */
130
180
  public setAjax(config: AjaxConfig | null): void {
131
181
  this.ajaxConfig = config;
@@ -134,7 +184,7 @@ export class SearchController {
134
184
  /**
135
185
  * Attaches a Popup instance to allow UI updates during search (e.g., loading, resize).
136
186
  *
137
- * @param {Popup} popupInstance - The popup used to display search results and loading state.
187
+ * @param popupInstance - The popup used to display search results and loading state.
138
188
  */
139
189
  public setPopup(popupInstance: Popup): void {
140
190
  this.popup = popupInstance;
@@ -142,6 +192,8 @@ export class SearchController {
142
192
 
143
193
  /**
144
194
  * Returns a shallow copy of the current pagination state used for search/infinite scroll.
195
+ *
196
+ * @returns Pagination state snapshot.
145
197
  */
146
198
  public getPaginationState(): PaginationState {
147
199
  return { ...this.paginationState };
@@ -164,6 +216,8 @@ export class SearchController {
164
216
 
165
217
  /**
166
218
  * Clears the current keyword and makes all options visible (local reset).
219
+ *
220
+ * No network requests are made; operates on the current model set.
167
221
  */
168
222
  public clear(): void {
169
223
  this.paginationState.currentKeyword = "";
@@ -183,14 +237,20 @@ export class SearchController {
183
237
 
184
238
  /**
185
239
  * Performs a search with either AJAX or local filtering depending on configuration.
240
+ *
241
+ * @param keyword - The search term to apply.
242
+ * @param append - For AJAX mode: whether to append results (next page). Defaults to false.
243
+ * @returns An implementation-specific result object.
186
244
  */
187
245
  public async search(keyword: string, append: boolean = false): Promise<any> {
188
- if (this.ajaxConfig) return this._ajaxSearch(keyword, append);
189
- return this._localSearch(keyword);
246
+ if (this.ajaxConfig) return this.ajaxSearch(keyword, append);
247
+ return this.localSearch(keyword);
190
248
  }
191
249
 
192
250
  /**
193
251
  * Loads the next page for AJAX pagination if enabled and not already loading.
252
+ *
253
+ * @returns Result of the paginated AJAX request, or an error when not applicable.
194
254
  */
195
255
  public async loadMore(): Promise<any> {
196
256
  if (!this.ajaxConfig) return { success: false, message: "Ajax not enabled" };
@@ -199,14 +259,17 @@ export class SearchController {
199
259
  if (!this.paginationState.hasMore) return { success: false, message: "No more data" };
200
260
 
201
261
  this.paginationState.currentPage++;
202
- return this._ajaxSearch(this.paginationState.currentKeyword, true);
262
+ return this.ajaxSearch(this.paginationState.currentKeyword, true);
203
263
  }
204
264
 
205
265
  /**
206
266
  * Executes a local (in-memory) search by normalizing the keyword (lowercase, non-accent)
207
- * and toggling each option's visibility based on text match. Returns summary flags.
267
+ * and toggling each option's visibility based on text match.
268
+ *
269
+ * @param keyword - Keyword to filter against local options.
270
+ * @returns Summary flags: `{ success, hasResults, isEmpty }`.
208
271
  */
209
- private async _localSearch(keyword: string): Promise<{ success: boolean; hasResults: boolean; isEmpty: boolean }> {
272
+ private async localSearch(keyword: string): Promise<{ success: boolean; hasResults: boolean; isEmpty: boolean }> {
210
273
  if (this.compareSearchTrigger(keyword)) this.paginationState.currentKeyword = keyword;
211
274
 
212
275
  const lower = String(keyword ?? "").toLowerCase();
@@ -229,6 +292,8 @@ export class SearchController {
229
292
  if (isVisible) hasVisibleItems = true;
230
293
  });
231
294
 
295
+ this.update();
296
+
232
297
  return {
233
298
  success: true,
234
299
  hasResults: hasVisibleItems,
@@ -239,6 +304,9 @@ export class SearchController {
239
304
  /**
240
305
  * Checks whether the provided keyword differs from the current one,
241
306
  * to determine if a new search should be triggered.
307
+ *
308
+ * @param keyword - The keyword to compare with the current search term.
309
+ * @returns True if a new search should be triggered; otherwise false.
242
310
  */
243
311
  public compareSearchTrigger(keyword: string): boolean {
244
312
  return keyword !== this.paginationState.currentKeyword;
@@ -246,8 +314,19 @@ export class SearchController {
246
314
 
247
315
  /**
248
316
  * Executes an AJAX-based search with optional appending.
317
+ *
318
+ * Behavior:
319
+ * - Aborts any in-flight request before starting a new search
320
+ * - Shows loading in the popup (if available)
321
+ * - Supports GET/POST with data built from config or function
322
+ * - Applies normalized results to the <select>, respecting `keepSelected`
323
+ * - Updates pagination state when server response includes pagination info
324
+ *
325
+ * @param keyword - Search keyword.
326
+ * @param append - Whether to append results (true = next page); defaults to false.
327
+ * @returns An implementation-specific result object with success and pagination flags.
249
328
  */
250
- private async _ajaxSearch(keyword: string, append: boolean = false): Promise<any> {
329
+ private async ajaxSearch(keyword: string, append: boolean = false): Promise<any> {
251
330
  const cfg = this.ajaxConfig!;
252
331
  if (this.compareSearchTrigger(keyword)) {
253
332
  this.resetPagination();
@@ -309,6 +388,8 @@ export class SearchController {
309
388
  this.paginationState.isLoading = false;
310
389
  this.popup?.hideLoading();
311
390
 
391
+ this.update();
392
+
312
393
  return {
313
394
  success: true,
314
395
  hasResults: result.items.length > 0,
@@ -330,7 +411,20 @@ export class SearchController {
330
411
  }
331
412
 
332
413
  /**
333
- * Parses various server response shapes into a normalized structure for options and groups.
414
+ * Normalizes various server response shapes into a standard structure for options and groups.
415
+ *
416
+ * Supported shapes (examples):
417
+ * - `{ object: [...], page?, totalPages?, hasMore? }`
418
+ * - `{ data: [...], page?, totalPages?, hasMore? }`
419
+ * - `{ items: [...], pagination: { page, totalPages, hasMore } }`
420
+ * - `[...]` (array of items)
421
+ *
422
+ * Each item can represent either:
423
+ * - An option: `{ type: "option", value, text, selected?, data? }`
424
+ * - A group: `{ type: "optgroup", label, data?, options: [...] }`
425
+ *
426
+ * @param data - Server response (any shape).
427
+ * @returns `{ items, hasPagination, page, totalPages, hasMore }`
334
428
  */
335
429
  private parseResponse(data: any): ParseResponseResult {
336
430
  let items: any[] = [];
@@ -400,6 +494,15 @@ export class SearchController {
400
494
 
401
495
  /**
402
496
  * Applies normalized AJAX results to the underlying <select> element.
497
+ *
498
+ * Behavior:
499
+ * - Optionally preserves existing selections (`keepSelected`)
500
+ * - Clears existing children unless `append` is true
501
+ * - Supports adding either normalized items or raw HTMLOption/HTMLOptGroup elements
502
+ *
503
+ * @param items - Normalized items (or raw HTMLOption/HTMLOptGroup).
504
+ * @param keepSelected - Preserve previously selected options.
505
+ * @param append - Append to existing options instead of replacing.
403
506
  */
404
507
  public applyAjaxResult(items: NormalizedAjaxItem[], keepSelected: boolean, append: boolean = false): void {
405
508
  const select = this.select;
@@ -410,7 +513,7 @@ export class SearchController {
410
513
  if (!append) select.innerHTML = "";
411
514
 
412
515
  items.forEach((item: any) => {
413
- // Skip empty item
516
+ // Skip empty item (defensive guard)
414
517
  if ((item["type"] === "option" || !item["type"]) && item["value"] === "" && item["text"] === "") return;
415
518
 
416
519
  if (item instanceof HTMLOptionElement || item instanceof HTMLOptGroupElement) {
@@ -467,7 +570,27 @@ export class SearchController {
467
570
  select.appendChild(option);
468
571
  }
469
572
  });
573
+ }
574
+
575
+ /**
576
+ * Destroys the controller and clears references.
577
+ *
578
+ * Notes:
579
+ * - In-flight requests are not aborted here; consumers should abort if needed before destroy.
580
+ * - The linked Popup/ModelManager exist outside this controller and are not destroyed here.
581
+ */
582
+ public override destroy(): void {
583
+ if (this.is(LifecycleState.DESTROYED)) {
584
+ return;
585
+ }
586
+
587
+ this.select = null;
588
+ this.modelManager = null;
589
+ this.ajaxConfig = null;
590
+ this.abortController = null;
591
+ this.popup = null;
592
+ this.selectBox = null;
470
593
 
471
- select.dispatchEvent(new CustomEvent("options:changed"));
594
+ super.destroy();
472
595
  }
473
596
  }
package/src/ts/global.ts CHANGED
@@ -18,13 +18,13 @@ import "../css/index.css";
18
18
  import "../css/components/selectbox.css";
19
19
  import "../css/components/placeholder.css";
20
20
  import "../css/components/directive.css";
21
- import "../css/components/empty-state.css";
22
- import "../css/components/loading-state.css";
23
- import "../css/components/optgroup.css";
24
- import "../css/components/popup.css";
21
+ import "../css/components/popup/empty-state.css";
22
+ import "../css/components/popup/loading-state.css";
23
+ import "../css/views/group-view.css";
24
+ import "../css/components/popup/popup.css";
25
25
  import "../css/components/searchbox.css";
26
26
  import "../css/components/option-handle.css";
27
- import "../css/components/option.css";
27
+ import "../css/views/option-view.css";
28
28
  import "../css/components/accessorybox.css";
29
29
 
30
30
  import { Selective } from "./utils/selective";
package/src/ts/index.ts CHANGED
@@ -18,13 +18,13 @@ import "../css/index.css";
18
18
  import "../css/components/selectbox.css";
19
19
  import "../css/components/placeholder.css";
20
20
  import "../css/components/directive.css";
21
- import "../css/components/empty-state.css";
22
- import "../css/components/loading-state.css";
23
- import "../css/components/optgroup.css";
24
- import "../css/components/popup.css";
21
+ import "../css/components/popup/empty-state.css";
22
+ import "../css/components/popup/loading-state.css";
23
+ import "../css/views/group-view.css";
24
+ import "../css/components/popup/popup.css";
25
25
  import "../css/components/searchbox.css";
26
26
  import "../css/components/option-handle.css";
27
- import "../css/components/option.css";
27
+ import "../css/views/option-view.css";
28
28
  import "../css/components/accessorybox.css";
29
29
 
30
30
  import { Selective } from "./utils/selective";
@@ -6,6 +6,7 @@ import { GroupView } from "../views/group-view";
6
6
  import { OptionModel } from "./option-model";
7
7
  import type { IEventCallback } from "../types/utils/ievents.type";
8
8
  import { SelectiveOptions } from "../types/utils/selective.type";
9
+ import { LifecycleState } from "../types/core/base/lifecycle.type";
9
10
 
10
11
  /**
11
12
  * @extends {Model<HTMLOptGroupElement, GroupViewTags, GroupView>}
@@ -28,11 +29,16 @@ export class GroupModel extends Model<HTMLOptGroupElement, GroupViewTags, GroupV
28
29
  */
29
30
  public constructor(options: SelectiveOptions, targetElement?: HTMLOptGroupElement) {
30
31
  super(options, targetElement ?? null, null);
31
-
32
- if (targetElement) {
33
- this.label = targetElement.label;
34
- this.collapsed = Libs.string2Boolean(targetElement.dataset?.collapsed);
32
+ }
33
+
34
+ public override init() {
35
+ if (this.targetElement) {
36
+ this.label = this.targetElement.label;
37
+ this.collapsed = Libs.string2Boolean(this.targetElement.dataset?.collapsed);
35
38
  }
39
+
40
+ super.init();
41
+ this.mount();
36
42
  }
37
43
 
38
44
  /**
@@ -76,20 +82,35 @@ export class GroupModel extends Model<HTMLOptGroupElement, GroupViewTags, GroupV
76
82
  *
77
83
  * @param {HTMLOptGroupElement} targetElement - The updated <optgroup> element.
78
84
  */
79
- public update(targetElement: HTMLOptGroupElement): void {
85
+ public updateTarget(targetElement: HTMLOptGroupElement): void {
80
86
  this.label = targetElement.label;
81
87
  this.view?.updateLabel(this.label);
88
+ this.update();
82
89
  }
83
90
 
84
91
  /**
85
92
  * Hook invoked when the target element reference changes.
86
93
  * Updates the view's label and collapsed state to keep UI in sync.
87
94
  */
88
- public onTargetChanged(): void {
95
+ public override update(): void {
89
96
  if (this.view) {
90
97
  this.view.updateLabel(this.label);
91
98
  this.view.setCollapsed(this.collapsed);
92
99
  }
100
+ super.update();
101
+ }
102
+
103
+ public override destroy(): void {
104
+ if (this.is(LifecycleState.DESTROYED)) {
105
+ return;
106
+ }
107
+
108
+ this.items.forEach(item => {
109
+ item.destroy();
110
+ });
111
+
112
+ this.items = [];
113
+ super.destroy();
93
114
  }
94
115
 
95
116
  /**
@@ -7,6 +7,7 @@ import type { IEventCallback } from "../types/utils/ievents.type";
7
7
  import type { OptionViewTags } from "../types/views/view.option.type";
8
8
  import type { GroupModel } from "./group-model";
9
9
  import { SelectiveOptions } from "../types/utils/selective.type";
10
+ import { LifecycleState } from "../types/core/base/lifecycle.type";
10
11
 
11
12
  /**
12
13
  * @extends {Model<HTMLOptionElement, OptionViewTags, OptionView, SelectiveOptions>}
@@ -34,10 +35,13 @@ export class OptionModel extends Model<HTMLOptionElement, OptionViewTags, Option
34
35
  */
35
36
  public constructor(options: SelectiveOptions, targetElement: HTMLOptionElement | null = null, view: OptionView | null = null) {
36
37
  super(options, targetElement, view);
37
-
38
- (async () => {
39
- this.textToFind = Libs.string2normalize(this.textContent.toLowerCase());
40
- })();
38
+ }
39
+
40
+ public override init(): void {
41
+ this.textToFind = Libs.string2normalize(this.textContent.toLowerCase());
42
+
43
+ super.init();
44
+ this.mount();
41
45
  }
42
46
 
43
47
  /**
@@ -231,9 +235,12 @@ export class OptionModel extends Model<HTMLOptionElement, OptionViewTags, Option
231
235
  * Updates label content (HTML or text), image src/alt if present,
232
236
  * and synchronizes initial selected state to the view.
233
237
  */
234
- public onTargetChanged(): void {
238
+ public override update(): void {
235
239
  this.textToFind = Libs.string2normalize(this.textContent.toLowerCase());
236
- if (!this.view) return;
240
+ if (!this.view) {
241
+ super.update();
242
+ return;
243
+ }
237
244
 
238
245
  const labelContent = this.view.view.tags.LabelContent;
239
246
  if (labelContent) {
@@ -251,5 +258,21 @@ export class OptionModel extends Model<HTMLOptionElement, OptionViewTags, Option
251
258
  }
252
259
 
253
260
  if (this.targetElement) this.selectedNonTrigger = this.targetElement.selected;
261
+
262
+ super.update();
263
+ }
264
+
265
+ public override destroy(): void {
266
+ if (this.is(LifecycleState.DESTROYED)) {
267
+ return;
268
+ }
269
+
270
+ this.privOnSelected = [];
271
+ this.privOnInternalSelected = [];
272
+ this.privOnVisibilityChanged = [];
273
+ this.group = null;
274
+ this.textToFind = null;
275
+
276
+ super.destroy();
254
277
  }
255
278
  }
@@ -25,12 +25,12 @@ export class ElementAdditionObserver<T extends Element = Element> {
25
25
  }
26
26
 
27
27
  /**
28
- * Starts observing the document for additions of elements matching the given tag.
28
+ * connect observing the document for additions of elements matching the given tag.
29
29
  * Detects both direct additions and nested matches within added subtrees.
30
30
  *
31
31
  * @param {string} tag - The tag name to watch for (e.g., "select", "div").
32
32
  */
33
- public start(tag: string): void {
33
+ public connect(tag: string): void {
34
34
  if (this.isActive) return;
35
35
 
36
36
  this.isActive = true;
@@ -43,14 +43,14 @@ export class ElementAdditionObserver<T extends Element = Element> {
43
43
  mutation.addedNodes.forEach((node) => {
44
44
  if (node.nodeType !== 1) return;
45
45
 
46
- const subnode = node as HTMLElement;
46
+ const subnode = node as T;
47
47
 
48
48
  if (subnode.tagName === upperTag) {
49
- this.handle(subnode as unknown as T);
49
+ this.handle(subnode as T);
50
50
  }
51
51
 
52
52
  const matches = subnode.querySelectorAll(lowerTag);
53
- matches.forEach((el) => this.handle(el as unknown as T));
53
+ matches.forEach((el) => this.handle(el as T));
54
54
  });
55
55
  }
56
56
  });
@@ -65,7 +65,7 @@ export class ElementAdditionObserver<T extends Element = Element> {
65
65
  * Stops observing for element additions and releases internal resources.
66
66
  * No-ops if the observer is not active.
67
67
  */
68
- public stop(): void {
68
+ public disconnect(): void {
69
69
  if (!this.isActive) return;
70
70
 
71
71
  this.isActive = false;
@@ -326,7 +326,7 @@ class EffectorImpl implements EffectorInterface {
326
326
  this.element.style.transition = `top ${duration}ms ease-out, height ${duration}ms ease-out, max-height ${duration}ms ease-out;`;
327
327
  }
328
328
 
329
- setTimeout(() => {
329
+ requestAnimationFrame(() => {
330
330
  const styles: Partial<CSSStyleDeclaration> & Record<string, string> = {
331
331
  width: `${width}px`,
332
332
  left: `${left}px`,
@@ -360,7 +360,7 @@ class EffectorImpl implements EffectorInterface {
360
360
  if (isPositionChanged) delete this.element.style.transition;
361
361
  onComplete?.();
362
362
  }
363
- }, 20);
363
+ });
364
364
 
365
365
  return this;
366
366
  }