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.
- package/dist/selective-ui.css.map +1 -1
- package/dist/selective-ui.esm.js +2178 -695
- package/dist/selective-ui.esm.js.map +1 -1
- package/dist/selective-ui.esm.min.js +2 -2
- package/dist/selective-ui.esm.min.js.br +0 -0
- package/dist/selective-ui.min.js +2 -2
- package/dist/selective-ui.min.js.br +0 -0
- package/dist/selective-ui.umd.js +2179 -696
- package/dist/selective-ui.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/ts/adapter/mixed-adapter.ts +167 -80
- package/src/ts/components/accessorybox.ts +155 -34
- package/src/ts/components/directive.ts +62 -11
- package/src/ts/components/option-handle.ts +124 -28
- package/src/ts/components/placeholder.ts +73 -16
- package/src/ts/components/popup/empty-state.ts +126 -0
- package/src/ts/components/popup/loading-state.ts +120 -0
- package/src/ts/components/{popup.ts → popup/popup.ts} +169 -72
- package/src/ts/components/searchbox.ts +82 -16
- package/src/ts/components/selectbox.ts +207 -115
- package/src/ts/core/base/adapter.ts +121 -55
- package/src/ts/core/base/lifecycle.ts +175 -0
- package/src/ts/core/base/model.ts +63 -32
- package/src/ts/core/base/recyclerview.ts +56 -18
- package/src/ts/core/base/view.ts +56 -19
- package/src/ts/core/base/virtual-recyclerview.ts +322 -128
- package/src/ts/core/model-manager.ts +18 -11
- package/src/ts/core/search-controller.ts +148 -25
- package/src/ts/global.ts +5 -5
- package/src/ts/index.ts +5 -5
- package/src/ts/models/group-model.ts +27 -6
- package/src/ts/models/option-model.ts +29 -6
- package/src/ts/services/ea-observer.ts +6 -6
- package/src/ts/services/effector.ts +2 -2
- package/src/ts/services/select-observer.ts +1 -8
- package/src/ts/types/components/searchbox.type.ts +1 -1
- package/src/ts/types/core/base/adapter.type.ts +2 -1
- package/src/ts/types/core/base/lifecycle.type.ts +62 -0
- package/src/ts/types/core/base/model.type.ts +3 -1
- package/src/ts/types/core/base/recyclerview.type.ts +2 -8
- package/src/ts/types/core/base/view.type.ts +36 -24
- package/src/ts/utils/callback-scheduler.ts +38 -18
- package/src/ts/utils/istorage.ts +1 -1
- package/src/ts/utils/selective.ts +153 -36
- package/src/ts/views/group-view.ts +59 -21
- package/src/ts/views/option-view.ts +137 -68
- package/src/ts/components/empty-state.ts +0 -68
- package/src/ts/components/loading-state.ts +0 -66
- /package/src/css/components/{empty-state.css → popup/empty-state.css} +0 -0
- /package/src/css/components/{loading-state.css → popup/loading-state.css} +0 -0
- /package/src/css/components/{popup.css → popup/popup.css} +0 -0
- /package/src/css/{components/optgroup.css → views/group-view.css} +0 -0
- /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.
|
|
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.
|
|
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.
|
|
285
|
+
removedGroup.destroy();
|
|
279
286
|
});
|
|
280
287
|
|
|
281
288
|
oldOptionMap.forEach((removedOption) => {
|
|
282
289
|
isUpdate = false;
|
|
283
|
-
removedOption.
|
|
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
|
-
|
|
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
|
|
38
|
-
* @param
|
|
39
|
-
* @param
|
|
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
|
|
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
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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"
|
|
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
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* @
|
|
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
|
|
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
|
|
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.
|
|
189
|
-
return this.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
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
|
|
238
|
+
public override update(): void {
|
|
235
239
|
this.textToFind = Libs.string2normalize(this.textContent.toLowerCase());
|
|
236
|
-
if (!this.view)
|
|
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
|
-
*
|
|
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
|
|
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
|
|
46
|
+
const subnode = node as T;
|
|
47
47
|
|
|
48
48
|
if (subnode.tagName === upperTag) {
|
|
49
|
-
this.handle(subnode as
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|
|
363
|
+
});
|
|
364
364
|
|
|
365
365
|
return this;
|
|
366
366
|
}
|