selective-ui 1.0.2 → 1.0.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 (67) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +7 -2
  3. package/dist/selective-ui.css +567 -567
  4. package/dist/selective-ui.css.map +1 -1
  5. package/dist/selective-ui.esm.js +6186 -6047
  6. package/dist/selective-ui.esm.js.map +1 -1
  7. package/dist/selective-ui.esm.min.js +1 -1
  8. package/dist/selective-ui.esm.min.js.br +0 -0
  9. package/dist/selective-ui.min.js +2 -2
  10. package/dist/selective-ui.min.js.br +0 -0
  11. package/dist/selective-ui.umd.js +6186 -6047
  12. package/dist/selective-ui.umd.js.map +1 -1
  13. package/package.json +68 -68
  14. package/src/css/components/accessorybox.css +63 -63
  15. package/src/css/components/directive.css +19 -19
  16. package/src/css/components/empty-state.css +25 -25
  17. package/src/css/components/loading-state.css +25 -25
  18. package/src/css/components/optgroup.css +61 -61
  19. package/src/css/components/option-handle.css +33 -33
  20. package/src/css/components/option.css +129 -129
  21. package/src/css/components/placeholder.css +14 -14
  22. package/src/css/components/popup.css +38 -38
  23. package/src/css/components/searchbox.css +28 -28
  24. package/src/css/components/selectbox.css +53 -53
  25. package/src/css/index.css +74 -74
  26. package/src/js/adapter/mixed-adapter.js +434 -434
  27. package/src/js/components/accessorybox.js +124 -124
  28. package/src/js/components/directive.js +37 -37
  29. package/src/js/components/empty-state.js +67 -67
  30. package/src/js/components/loading-state.js +59 -59
  31. package/src/js/components/option-handle.js +113 -113
  32. package/src/js/components/placeholder.js +56 -56
  33. package/src/js/components/popup.js +470 -470
  34. package/src/js/components/searchbox.js +167 -167
  35. package/src/js/components/selectbox.js +749 -692
  36. package/src/js/core/base/adapter.js +162 -162
  37. package/src/js/core/base/model.js +59 -59
  38. package/src/js/core/base/recyclerview.js +82 -82
  39. package/src/js/core/base/view.js +62 -62
  40. package/src/js/core/model-manager.js +286 -286
  41. package/src/js/core/search-controller.js +603 -521
  42. package/src/js/index.js +136 -136
  43. package/src/js/models/group-model.js +142 -142
  44. package/src/js/models/option-model.js +236 -236
  45. package/src/js/services/dataset-observer.js +73 -73
  46. package/src/js/services/ea-observer.js +87 -87
  47. package/src/js/services/effector.js +403 -403
  48. package/src/js/services/refresher.js +39 -39
  49. package/src/js/services/resize-observer.js +151 -151
  50. package/src/js/services/select-observer.js +60 -60
  51. package/src/js/types/adapter.type.js +32 -32
  52. package/src/js/types/effector.type.js +23 -23
  53. package/src/js/types/ievents.type.js +10 -10
  54. package/src/js/types/libs.type.js +27 -27
  55. package/src/js/types/model.type.js +11 -11
  56. package/src/js/types/recyclerview.type.js +11 -11
  57. package/src/js/types/resize-observer.type.js +18 -18
  58. package/src/js/types/view.group.type.js +12 -12
  59. package/src/js/types/view.option.type.js +14 -14
  60. package/src/js/types/view.type.js +10 -10
  61. package/src/js/utils/guard.js +46 -46
  62. package/src/js/utils/ievents.js +83 -83
  63. package/src/js/utils/istorage.js +60 -60
  64. package/src/js/utils/libs.js +618 -618
  65. package/src/js/utils/selective.js +385 -385
  66. package/src/js/views/group-view.js +102 -102
  67. package/src/js/views/option-view.js +152 -152
@@ -1,522 +1,604 @@
1
- import { Popup } from "../components/popup";
2
- import { GroupModel } from "../models/group-model";
3
- import { OptionModel } from "../models/option-model";
4
- import { Libs } from "../utils/libs";
5
- import { ModelManager } from "./model-manager";
6
-
7
- export class SearchController {
8
- #select;
9
- /** @type {ModelManager<OptionModel>} */
10
- #modelManager;
11
-
12
- #ajaxConfig = null;
13
-
14
- #abortController = null;
15
-
16
- /** @type {Popup} */
17
- #popup = null;
18
-
19
- #paginationState = {
20
- currentPage: 0,
21
- totalPages: 1,
22
- hasMore: false,
23
- isLoading: false,
24
- currentKeyword: "",
25
- isPaginationEnabled: false
26
- };
27
-
28
- /**
29
- * Initializes the SearchController with a source <select> element and a ModelManager
30
- * to manage option models and search results.
31
- *
32
- * @param {HTMLSelectElement} selectElement - The native select element that provides context and data source.
33
- * @param {ModelManager<OptionModel>} modelManager - Manager responsible for models and rendering updates.
34
- */
35
- constructor(selectElement, modelManager) {
36
- this.#select = selectElement;
37
- this.#modelManager = modelManager;
38
- }
39
-
40
- /**
41
- * Indicates whether AJAX-based search is configured.
42
- *
43
- * @returns {boolean} - True if AJAX config is present; false otherwise.
44
- */
45
- isAjax() {
46
- return !(!this.#ajaxConfig);
47
- }
48
-
49
- /**
50
- * Configures AJAX settings used for remote searching and pagination.
51
- *
52
- * @param {object} config - AJAX configuration object (e.g., endpoint, headers, query params).
53
- */
54
- setAjax(config) {
55
- this.#ajaxConfig = config;
56
- }
57
-
58
- /**
59
- * Attaches a Popup instance to allow UI updates during search (e.g., loading, resize).
60
- *
61
- * @param {Popup} popupInstance - The popup used to display search results and loading state.
62
- */
63
- setPopup(popupInstance) {
64
- this.#popup = popupInstance;
65
- }
66
-
67
-
68
- /**
69
- * Returns a shallow copy of the current pagination state used for search/infinite scroll.
70
- *
71
- * @returns {{
72
- * currentPage:number, totalPages:number, hasMore:boolean, isLoading:boolean,
73
- * currentKeyword:string, isPaginationEnabled:boolean
74
- * }}
75
- */
76
- getPaginationState() {
77
- return { ...this.#paginationState };
78
- }
79
-
80
- /**
81
- * Resets pagination counters while preserving whether pagination is enabled.
82
- * Clears page, totals, loading flags, and current keyword.
83
- */
84
- resetPagination() {
85
- this.#paginationState = {
86
- currentPage: 0,
87
- totalPages: 1,
88
- hasMore: false,
89
- isLoading: false,
90
- currentKeyword: "",
91
- isPaginationEnabled: this.#paginationState.isPaginationEnabled
92
- };
93
- }
94
-
95
- /**
96
- * Clears the current keyword and makes all options visible (local reset).
97
- * Flattens groups and options, then sets `visible = true` for each option.
98
- */
99
- clear() {
100
- this.#paginationState.currentKeyword = "";
101
- const { modelList } = this.#modelManager.getResources();
102
- const flatOptions = [];
103
- for (const m of modelList) {
104
- if (m instanceof OptionModel) flatOptions.push(m);
105
- else if (m instanceof GroupModel && Array.isArray(m.items)) flatOptions.push(...m.items);
106
- }
107
- flatOptions.forEach(opt => { opt.visible = true; });
108
- }
109
-
110
- /**
111
- * Performs a search with either AJAX or local filtering depending on configuration.
112
- *
113
- * @param {string} keyword - The search term to apply.
114
- * @param {boolean} [append=false] - When using AJAX, whether to append results to existing items.
115
- * @returns {Promise<{success:boolean, hasResults:boolean, isEmpty:boolean} | any>}
116
- */
117
- async search(keyword, append = false) {
118
- if (this.#ajaxConfig && this.#ajaxConfig) {
119
- return this.#ajaxSearch(keyword, append);
120
- }
121
- return this.#localSearch(keyword);
122
- }
123
-
124
- /**
125
- * Loads the next page for AJAX pagination if enabled and not already loading,
126
- * otherwise returns an error object indicating the reason.
127
- *
128
- * @returns {Promise<{success:boolean, message?:string} | any>}
129
- */
130
- async loadMore() {
131
- if (!this.#ajaxConfig || !this.#ajaxConfig) {
132
- return { success: false, message: "Ajax not enabled" };
133
- }
134
-
135
- if (this.#paginationState.isLoading) {
136
- return { success: false, message: "Already loading" };
137
- }
138
-
139
- if (!this.#paginationState.isPaginationEnabled) {
140
- return { success: false, message: "Pagination not enabled" };
141
- }
142
-
143
- if (!this.#paginationState.hasMore) {
144
- return { success: false, message: "No more data" };
145
- }
146
-
147
- this.#paginationState.currentPage++;
148
- return this.#ajaxSearch(this.#paginationState.currentKeyword, true);
149
- }
150
-
151
- /**
152
- * Executes a local (in-memory) search by normalizing the keyword (lowercase, non-accent)
153
- * and toggling each option's visibility based on text match. Returns summary flags.
154
- *
155
- * @param {string} keyword - The search term.
156
- * @returns {Promise<{success:boolean, hasResults:boolean, isEmpty:boolean}>}
157
- */
158
- async #localSearch(keyword) {
159
- if (this.compareSearchTrigger(keyword)) {
160
- this.#paginationState.currentKeyword = keyword;
161
- }
162
-
163
- const lower = String(keyword || "").toLowerCase();
164
- const lowerNA = Libs.string2normalize(lower);
165
-
166
- const { modelList } = this.#modelManager.getResources();
167
- const flatOptions = [];
168
- for (const m of modelList) {
169
- if (m instanceof OptionModel) {
170
- flatOptions.push(m);
171
- } else if (m instanceof GroupModel && Array.isArray(m.items)) {
172
- flatOptions.push(...m.items);
173
- }
174
- }
175
-
176
- let hasVisibleItems = false;
177
- flatOptions.forEach(opt => {
178
- const text = String(opt.textContent || opt.text || "").toLowerCase();
179
- const textNA = Libs.string2normalize(text);
180
- const isVisible =
181
- lower === "" ||
182
- text.includes(lower) ||
183
- textNA.includes(lowerNA);
184
- opt.visible = isVisible;
185
- if (isVisible) hasVisibleItems = true;
186
- });
187
-
188
- return {
189
- success: true,
190
- hasResults: hasVisibleItems,
191
- isEmpty: flatOptions.length === 0
192
- };
193
- }
194
-
195
- /**
196
- * Checks whether the provided keyword differs from the current one,
197
- * to determine if a new search should be triggered.
198
- *
199
- * @param {string} keyword - The candidate search term.
200
- * @returns {boolean} - True if different from the current keyword; otherwise false.
201
- */
202
- compareSearchTrigger(keyword) {
203
- if (keyword !== this.#paginationState.currentKeyword) {
204
- return true;
205
- }
206
- return false;
207
- }
208
-
209
- /**
210
- * Executes an AJAX-based search with optional appending. Manages pagination,
211
- * aborts previous requests, shows/hides loading, builds payload, and applies results.
212
- *
213
- * @param {string} keyword - The search term.
214
- * @param {boolean} [append=false] - Whether to append results instead of replacing.
215
- * @returns {Promise<{
216
- * success:boolean, hasResults:boolean, isEmpty:boolean,
217
- * hasPagination:boolean, hasMore:boolean, currentPage:number, totalPages:number
218
- * } | {success:false, message:string}>}
219
- */
220
- async #ajaxSearch(keyword, append = false) {
221
- const cfg = this.#ajaxConfig;
222
-
223
- if (this.compareSearchTrigger(keyword)) {
224
- this.resetPagination();
225
- this.#paginationState.currentKeyword = keyword;
226
- append = false;
227
- }
228
-
229
- this.#paginationState.isLoading = true;
230
- this.#popup?.showLoading();
231
-
232
- this.#abortController?.abort();
233
- this.#abortController = new AbortController();
234
-
235
- const page = this.#paginationState.currentPage;
236
-
237
- const selectedValues = Array.from(this.#select.selectedOptions)
238
- .map(opt => opt.value)
239
- .join(",");
240
-
241
- let payload;
242
- if (typeof cfg.data === "function") {
243
- payload = cfg.data(keyword, page);
244
- if (payload && !payload.selectedValue) {
245
- payload.selectedValue = selectedValues;
246
- }
247
- } else {
248
- payload = {
249
- search: keyword,
250
- page: page,
251
- selectedValue: selectedValues,
252
- ...(cfg.data || {})
253
- };
254
- }
255
-
256
- try {
257
- let response;
258
-
259
- if (cfg.method === "POST") {
260
- const formData = new URLSearchParams();
261
- Object.keys(payload).forEach(key => {
262
- formData.append(key, payload[key]);
263
- });
264
-
265
- response = await fetch(cfg.url, {
266
- method: "POST",
267
- body: formData,
268
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
269
- signal: this.#abortController.signal
270
- });
271
- } else {
272
- const params = new URLSearchParams(payload).toString();
273
- response = await fetch(`${cfg.url}?${params}`, {
274
- signal: this.#abortController.signal
275
- });
276
- }
277
-
278
- let data = await response.json();
279
-
280
- const result = this.#parseResponse(data);
281
-
282
- if (result.hasPagination) {
283
- this.#paginationState.isPaginationEnabled = true;
284
- this.#paginationState.currentPage = result.page;
285
- this.#paginationState.totalPages = result.totalPages;
286
- this.#paginationState.hasMore = result.hasMore;
287
- } else {
288
- this.#paginationState.isPaginationEnabled = false;
289
- }
290
-
291
- this.#applyAjaxResult(result.items, cfg.keepSelected, append);
292
-
293
- this.#paginationState.isLoading = false;
294
- this.#popup?.hideLoading();
295
-
296
- return {
297
- success: true,
298
- hasResults: result.items.length > 0,
299
- isEmpty: result.items.length === 0,
300
- hasPagination: result.hasPagination,
301
- hasMore: result.hasMore,
302
- currentPage: result.page,
303
- totalPages: result.totalPages
304
- };
305
- } catch (error) {
306
- this.#paginationState.isLoading = false;
307
- this.#popup?.hideLoading();
308
-
309
- if (error.name === "AbortError") {
310
- return { success: false, message: "Request aborted" };
311
- }
312
-
313
- console.error("Ajax search error:", error);
314
- return { success: false, message: error.message };
315
- }
316
- }
317
-
318
- /**
319
- * Parses various server response shapes into a normalized structure for options and groups.
320
- * Supports arrays at keys: `object`, `data`, `items`, or a root array; detects pagination metadata.
321
- * Each item is mapped to either an "option" or "optgroup" descriptor, preserving custom data fields.
322
- *
323
- * @param {any} data - The raw response payload from the AJAX request.
324
- * @returns {{
325
- * items: Array<
326
- * | HTMLOptionElement
327
- * | HTMLOptGroupElement
328
- * | {
329
- * type: "option",
330
- * value: string,
331
- * text: string,
332
- * selected?: boolean,
333
- * data?: Record<string, any>
334
- * }
335
- * | {
336
- * type: "optgroup",
337
- * label: string,
338
- * data?: Record<string, any>,
339
- * options: Array<{
340
- * value: string,
341
- * text: string,
342
- * selected?: boolean,
343
- * data?: Record<string, any>
344
- * }>
345
- * }
346
- * >,
347
- * hasPagination: boolean,
348
- * page: number,
349
- * totalPages: number,
350
- * hasMore: boolean
351
- * }}
352
- */
353
- #parseResponse(data) {
354
- let items = [];
355
- let hasPagination = false;
356
- let page = 0;
357
- let totalPages = 1;
358
- let hasMore = false;
359
-
360
- if (data.object && Array.isArray(data.object)) {
361
- items = data.object;
362
- if (typeof data.page !== "undefined") {
363
- hasPagination = true;
364
- page = parseInt(data.page) || 0;
365
- totalPages = parseInt(data.totalPages || data.total_page) || 1;
366
- hasMore = page < totalPages - 1;
367
- }
368
- }
369
- else if (data.data && Array.isArray(data.data)) {
370
- items = data.data;
371
- if (typeof data.page !== "undefined") {
372
- hasPagination = true;
373
- page = parseInt(data.page) || 0;
374
- totalPages = parseInt(data.totalPages || data.total_page) || 1;
375
- hasMore = data.hasMore ?? (page < totalPages - 1);
376
- }
377
- }
378
- else if (Array.isArray(data)) {
379
- items = data;
380
- }
381
- else if (data.items && Array.isArray(data.items)) {
382
- items = data.items;
383
- if (data.pagination) {
384
- hasPagination = true;
385
- page = parseInt(data.pagination.page) || 0;
386
- totalPages = parseInt(data.pagination.totalPages || data.pagination.total_page) || 1;
387
- hasMore = data.pagination.hasMore ?? (page < totalPages - 1);
388
- }
389
- }
390
-
391
- items = items.map(item => {
392
- if (item instanceof HTMLOptionElement || item instanceof HTMLOptGroupElement) {
393
- return item;
394
- }
395
-
396
- if (item.type === "optgroup" || item.isGroup || item.group || item.label) {
397
- return {
398
- type: "optgroup",
399
- label: item.label || item.name || item.title || "",
400
- data: item.data || {},
401
- options: (item.options || item.items || []).map(opt => ({
402
- value: opt.value || opt.id || opt.key || "",
403
- text: opt.text || opt.label || opt.name || opt.title || "",
404
- selected: opt.selected || false,
405
- data: opt.data || (opt.imgsrc ? { imgsrc: opt.imgsrc } : {})
406
- }))
407
- };
408
- }
409
-
410
- let data = item.data || {};
411
- if (item?.imgsrc) {
412
- data.imgsrc = item.imgsrc;
413
- }
414
-
415
- return {
416
- type: "option",
417
- value: item.value || item.id || item.key || "",
418
- text: item.text || item.label || item.name || item.title || "",
419
- selected: item.selected || false,
420
- data: data
421
- };
422
- });
423
-
424
- return {
425
- items,
426
- hasPagination,
427
- page,
428
- totalPages,
429
- hasMore
430
- };
431
- }
432
-
433
- /**
434
- * Applies normalized AJAX results to the underlying <select> element.
435
- * Optionally keeps previous selections, supports appending, and preserves
436
- * custom data attributes for both options and optgroups. Emits "options:changed".
437
- *
438
- * @param {Array<
439
- * | HTMLOptionElement
440
- * | HTMLOptGroupElement
441
- * | {type:"option", value:string, text:string, selected?:boolean, data?:Record<string, any>}
442
- * | {type:"optgroup", label:string, data?:Record<string, any>, options:Array<{value:string, text:string, selected?:boolean, data?:Record<string, any>}>}
443
- * >} items - The normalized list of items to apply.
444
- * @param {boolean} keepSelected - If true, previously selected values are preserved when possible.
445
- * @param {boolean} [append=false] - If true, append to existing options; otherwise replace them.
446
- */
447
- #applyAjaxResult(items, keepSelected, append = false) {
448
- const select = this.#select;
449
-
450
- let oldSelected = [];
451
- if (keepSelected) {
452
- oldSelected = Array.from(select.selectedOptions).map(o => o.value);
453
- }
454
-
455
- if (!append) {
456
- select.innerHTML = "";
457
- }
458
-
459
- items.forEach(item => {
460
- if ((item["type"] === "option" || !item["type"]) && item["value"] === "" && item["text"] === "") {
461
- return;
462
- }
463
-
464
- if (item instanceof HTMLOptionElement || item instanceof HTMLOptGroupElement) {
465
- select.appendChild(item);
466
- return;
467
- }
468
-
469
- if (item.type === "optgroup") {
470
- const optgroup = document.createElement("optgroup");
471
- optgroup.label = item.label;
472
-
473
- if (item.data) {
474
- Object.keys(item.data).forEach(key => {
475
- optgroup.dataset[key] = item.data[key];
476
- });
477
- }
478
-
479
- if (item.options && Array.isArray(item.options)) {
480
- item.options.forEach(opt => {
481
- const option = document.createElement("option");
482
- option.value = opt.value;
483
- option.text = opt.text;
484
-
485
- if (opt.data) {
486
- Object.keys(opt.data).forEach(key => {
487
- option.dataset[key] = opt.data[key];
488
- });
489
- }
490
-
491
- if (opt.selected || (keepSelected && oldSelected.includes(option.value))) {
492
- option.selected = true;
493
- }
494
-
495
- optgroup.appendChild(option);
496
- });
497
- }
498
-
499
- select.appendChild(optgroup);
500
- }
501
- else {
502
- const option = document.createElement("option");
503
- option.value = item.value;
504
- option.text = item.text;
505
-
506
- if (item.data) {
507
- Object.keys(item.data).forEach(key => {
508
- option.dataset[key] = item.data[key];
509
- });
510
- }
511
-
512
- if (item.selected || (keepSelected && oldSelected.includes(option.value))) {
513
- option.selected = true;
514
- }
515
-
516
- select.appendChild(option);
517
- }
518
- });
519
-
520
- select.dispatchEvent(new CustomEvent("options:changed"));
521
- }
1
+ import { Popup } from "../components/popup";
2
+ import { GroupModel } from "../models/group-model";
3
+ import { OptionModel } from "../models/option-model";
4
+ import { Libs } from "../utils/libs";
5
+ import { ModelManager } from "./model-manager";
6
+
7
+ export class SearchController {
8
+ #select;
9
+ /** @type {ModelManager<OptionModel>} */
10
+ #modelManager;
11
+
12
+ #ajaxConfig = null;
13
+
14
+ #abortController = null;
15
+
16
+ /** @type {Popup} */
17
+ #popup = null;
18
+
19
+ #paginationState = {
20
+ currentPage: 0,
21
+ totalPages: 1,
22
+ hasMore: false,
23
+ isLoading: false,
24
+ currentKeyword: "",
25
+ isPaginationEnabled: false
26
+ };
27
+
28
+ /**
29
+ * Initializes the SearchController with a source <select> element and a ModelManager
30
+ * to manage option models and search results.
31
+ *
32
+ * @param {HTMLSelectElement} selectElement - The native select element that provides context and data source.
33
+ * @param {ModelManager<OptionModel>} modelManager - Manager responsible for models and rendering updates.
34
+ */
35
+ constructor(selectElement, modelManager) {
36
+ this.#select = selectElement;
37
+ this.#modelManager = modelManager;
38
+ }
39
+
40
+ /**
41
+ * Indicates whether AJAX-based search is configured.
42
+ *
43
+ * @returns {boolean} - True if AJAX config is present; false otherwise.
44
+ */
45
+ isAjax() {
46
+ return !(!this.#ajaxConfig);
47
+ }
48
+
49
+ /**
50
+ * Load specific options by their values from server
51
+ * @param {string|string[]} values - Values to load
52
+ * @returns {Promise<{success: boolean, items: Array, message?: string}>}
53
+ */
54
+ async loadByValues(values) {
55
+ if (!this.#ajaxConfig) {
56
+ return { success: false, items: [], message: "Ajax not configured" };
57
+ }
58
+
59
+ const valuesArray = Array.isArray(values) ? values : [values];
60
+ if (valuesArray.length === 0) {
61
+ return { success: true, items: [] };
62
+ }
63
+
64
+ try {
65
+ const cfg = this.#ajaxConfig;
66
+
67
+ let payload;
68
+ if (typeof cfg.dataByValues === "function") {
69
+ payload = cfg.dataByValues(valuesArray);
70
+ } else {
71
+ payload = {
72
+ values: valuesArray.join(","),
73
+ load_by_values: "1",
74
+ ...(typeof cfg.data === "function" ? cfg.data("", 0) : (cfg.data || {}))
75
+ };
76
+ }
77
+
78
+ let response;
79
+ if (cfg.method === "POST") {
80
+ const formData = new URLSearchParams();
81
+ Object.keys(payload).forEach(key => {
82
+ formData.append(key, payload[key]);
83
+ });
84
+
85
+ response = await fetch(cfg.url, {
86
+ method: "POST",
87
+ body: formData,
88
+ headers: { "Content-Type": "application/x-www-form-urlencoded" }
89
+ });
90
+ } else {
91
+ const params = new URLSearchParams(payload).toString();
92
+ response = await fetch(`${cfg.url}?${params}`);
93
+ }
94
+
95
+ if (!response.ok) {
96
+ throw new Error(`HTTP error! status: ${response.status}`);
97
+ }
98
+
99
+ const data = await response.json();
100
+ const result = this.#parseResponse(data);
101
+
102
+ return {
103
+ success: true,
104
+ items: result.items
105
+ };
106
+ } catch (error) {
107
+ console.error("Load by values error:", error);
108
+ return {
109
+ success: false,
110
+ message: error.message,
111
+ items: []
112
+ };
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Check if values exist in current options
118
+ * @param {string[]} values - Values to check
119
+ * @returns {{existing: string[], missing: string[]}}
120
+ */
121
+ checkMissingValues(values) {
122
+ const allOptions = Array.from(this.#select.options);
123
+ const existingValues = allOptions.map(opt => opt.value);
124
+
125
+ const existing = values.filter(v => existingValues.includes(v));
126
+ const missing = values.filter(v => !existingValues.includes(v));
127
+
128
+ return { existing, missing };
129
+ }
130
+
131
+ /**
132
+ * Configures AJAX settings used for remote searching and pagination.
133
+ *
134
+ * @param {object} config - AJAX configuration object (e.g., endpoint, headers, query params).
135
+ */
136
+ setAjax(config) {
137
+ this.#ajaxConfig = config;
138
+ }
139
+
140
+ /**
141
+ * Attaches a Popup instance to allow UI updates during search (e.g., loading, resize).
142
+ *
143
+ * @param {Popup} popupInstance - The popup used to display search results and loading state.
144
+ */
145
+ setPopup(popupInstance) {
146
+ this.#popup = popupInstance;
147
+ }
148
+
149
+
150
+ /**
151
+ * Returns a shallow copy of the current pagination state used for search/infinite scroll.
152
+ *
153
+ * @returns {{
154
+ * currentPage:number, totalPages:number, hasMore:boolean, isLoading:boolean,
155
+ * currentKeyword:string, isPaginationEnabled:boolean
156
+ * }}
157
+ */
158
+ getPaginationState() {
159
+ return { ...this.#paginationState };
160
+ }
161
+
162
+ /**
163
+ * Resets pagination counters while preserving whether pagination is enabled.
164
+ * Clears page, totals, loading flags, and current keyword.
165
+ */
166
+ resetPagination() {
167
+ this.#paginationState = {
168
+ currentPage: 0,
169
+ totalPages: 1,
170
+ hasMore: false,
171
+ isLoading: false,
172
+ currentKeyword: "",
173
+ isPaginationEnabled: this.#paginationState.isPaginationEnabled
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Clears the current keyword and makes all options visible (local reset).
179
+ * Flattens groups and options, then sets `visible = true` for each option.
180
+ */
181
+ clear() {
182
+ this.#paginationState.currentKeyword = "";
183
+ const { modelList } = this.#modelManager.getResources();
184
+ const flatOptions = [];
185
+ for (const m of modelList) {
186
+ if (m instanceof OptionModel) flatOptions.push(m);
187
+ else if (m instanceof GroupModel && Array.isArray(m.items)) flatOptions.push(...m.items);
188
+ }
189
+ flatOptions.forEach(opt => { opt.visible = true; });
190
+ }
191
+
192
+ /**
193
+ * Performs a search with either AJAX or local filtering depending on configuration.
194
+ *
195
+ * @param {string} keyword - The search term to apply.
196
+ * @param {boolean} [append=false] - When using AJAX, whether to append results to existing items.
197
+ * @returns {Promise<{success:boolean, hasResults:boolean, isEmpty:boolean} | any>}
198
+ */
199
+ async search(keyword, append = false) {
200
+ if (this.#ajaxConfig && this.#ajaxConfig) {
201
+ return this.#ajaxSearch(keyword, append);
202
+ }
203
+ return this.#localSearch(keyword);
204
+ }
205
+
206
+ /**
207
+ * Loads the next page for AJAX pagination if enabled and not already loading,
208
+ * otherwise returns an error object indicating the reason.
209
+ *
210
+ * @returns {Promise<{success:boolean, message?:string} | any>}
211
+ */
212
+ async loadMore() {
213
+ if (!this.#ajaxConfig || !this.#ajaxConfig) {
214
+ return { success: false, message: "Ajax not enabled" };
215
+ }
216
+
217
+ if (this.#paginationState.isLoading) {
218
+ return { success: false, message: "Already loading" };
219
+ }
220
+
221
+ if (!this.#paginationState.isPaginationEnabled) {
222
+ return { success: false, message: "Pagination not enabled" };
223
+ }
224
+
225
+ if (!this.#paginationState.hasMore) {
226
+ return { success: false, message: "No more data" };
227
+ }
228
+
229
+ this.#paginationState.currentPage++;
230
+ return this.#ajaxSearch(this.#paginationState.currentKeyword, true);
231
+ }
232
+
233
+ /**
234
+ * Executes a local (in-memory) search by normalizing the keyword (lowercase, non-accent)
235
+ * and toggling each option's visibility based on text match. Returns summary flags.
236
+ *
237
+ * @param {string} keyword - The search term.
238
+ * @returns {Promise<{success:boolean, hasResults:boolean, isEmpty:boolean}>}
239
+ */
240
+ async #localSearch(keyword) {
241
+ if (this.compareSearchTrigger(keyword)) {
242
+ this.#paginationState.currentKeyword = keyword;
243
+ }
244
+
245
+ const lower = String(keyword || "").toLowerCase();
246
+ const lowerNA = Libs.string2normalize(lower);
247
+
248
+ const { modelList } = this.#modelManager.getResources();
249
+ const flatOptions = [];
250
+ for (const m of modelList) {
251
+ if (m instanceof OptionModel) {
252
+ flatOptions.push(m);
253
+ } else if (m instanceof GroupModel && Array.isArray(m.items)) {
254
+ flatOptions.push(...m.items);
255
+ }
256
+ }
257
+
258
+ let hasVisibleItems = false;
259
+ flatOptions.forEach(opt => {
260
+ const text = String(opt.textContent || opt.text || "").toLowerCase();
261
+ const textNA = Libs.string2normalize(text);
262
+ const isVisible =
263
+ lower === "" ||
264
+ text.includes(lower) ||
265
+ textNA.includes(lowerNA);
266
+ opt.visible = isVisible;
267
+ if (isVisible) hasVisibleItems = true;
268
+ });
269
+
270
+ return {
271
+ success: true,
272
+ hasResults: hasVisibleItems,
273
+ isEmpty: flatOptions.length === 0
274
+ };
275
+ }
276
+
277
+ /**
278
+ * Checks whether the provided keyword differs from the current one,
279
+ * to determine if a new search should be triggered.
280
+ *
281
+ * @param {string} keyword - The candidate search term.
282
+ * @returns {boolean} - True if different from the current keyword; otherwise false.
283
+ */
284
+ compareSearchTrigger(keyword) {
285
+ if (keyword !== this.#paginationState.currentKeyword) {
286
+ return true;
287
+ }
288
+ return false;
289
+ }
290
+
291
+ /**
292
+ * Executes an AJAX-based search with optional appending. Manages pagination,
293
+ * aborts previous requests, shows/hides loading, builds payload, and applies results.
294
+ *
295
+ * @param {string} keyword - The search term.
296
+ * @param {boolean} [append=false] - Whether to append results instead of replacing.
297
+ * @returns {Promise<{
298
+ * success:boolean, hasResults:boolean, isEmpty:boolean,
299
+ * hasPagination:boolean, hasMore:boolean, currentPage:number, totalPages:number
300
+ * } | {success:false, message:string}>}
301
+ */
302
+ async #ajaxSearch(keyword, append = false) {
303
+ const cfg = this.#ajaxConfig;
304
+
305
+ if (this.compareSearchTrigger(keyword)) {
306
+ this.resetPagination();
307
+ this.#paginationState.currentKeyword = keyword;
308
+ append = false;
309
+ }
310
+
311
+ this.#paginationState.isLoading = true;
312
+ this.#popup?.showLoading();
313
+
314
+ this.#abortController?.abort();
315
+ this.#abortController = new AbortController();
316
+
317
+ const page = this.#paginationState.currentPage;
318
+
319
+ const selectedValues = Array.from(this.#select.selectedOptions)
320
+ .map(opt => opt.value)
321
+ .join(",");
322
+
323
+ let payload;
324
+ if (typeof cfg.data === "function") {
325
+ payload = cfg.data(keyword, page);
326
+ if (payload && !payload.selectedValue) {
327
+ payload.selectedValue = selectedValues;
328
+ }
329
+ } else {
330
+ payload = {
331
+ search: keyword,
332
+ page: page,
333
+ selectedValue: selectedValues,
334
+ ...(cfg.data || {})
335
+ };
336
+ }
337
+
338
+ try {
339
+ let response;
340
+
341
+ if (cfg.method === "POST") {
342
+ const formData = new URLSearchParams();
343
+ Object.keys(payload).forEach(key => {
344
+ formData.append(key, payload[key]);
345
+ });
346
+
347
+ response = await fetch(cfg.url, {
348
+ method: "POST",
349
+ body: formData,
350
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
351
+ signal: this.#abortController.signal
352
+ });
353
+ } else {
354
+ const params = new URLSearchParams(payload).toString();
355
+ response = await fetch(`${cfg.url}?${params}`, {
356
+ signal: this.#abortController.signal
357
+ });
358
+ }
359
+
360
+ let data = await response.json();
361
+
362
+ const result = this.#parseResponse(data);
363
+
364
+ if (result.hasPagination) {
365
+ this.#paginationState.isPaginationEnabled = true;
366
+ this.#paginationState.currentPage = result.page;
367
+ this.#paginationState.totalPages = result.totalPages;
368
+ this.#paginationState.hasMore = result.hasMore;
369
+ } else {
370
+ this.#paginationState.isPaginationEnabled = false;
371
+ }
372
+
373
+ this.#applyAjaxResult(result.items, cfg.keepSelected, append);
374
+
375
+ this.#paginationState.isLoading = false;
376
+ this.#popup?.hideLoading();
377
+
378
+ return {
379
+ success: true,
380
+ hasResults: result.items.length > 0,
381
+ isEmpty: result.items.length === 0,
382
+ hasPagination: result.hasPagination,
383
+ hasMore: result.hasMore,
384
+ currentPage: result.page,
385
+ totalPages: result.totalPages
386
+ };
387
+ } catch (error) {
388
+ this.#paginationState.isLoading = false;
389
+ this.#popup?.hideLoading();
390
+
391
+ if (error.name === "AbortError") {
392
+ return { success: false, message: "Request aborted" };
393
+ }
394
+
395
+ console.error("Ajax search error:", error);
396
+ return { success: false, message: error.message };
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Parses various server response shapes into a normalized structure for options and groups.
402
+ * Supports arrays at keys: `object`, `data`, `items`, or a root array; detects pagination metadata.
403
+ * Each item is mapped to either an "option" or "optgroup" descriptor, preserving custom data fields.
404
+ *
405
+ * @param {any} data - The raw response payload from the AJAX request.
406
+ * @returns {{
407
+ * items: Array<
408
+ * | HTMLOptionElement
409
+ * | HTMLOptGroupElement
410
+ * | {
411
+ * type: "option",
412
+ * value: string,
413
+ * text: string,
414
+ * selected?: boolean,
415
+ * data?: Record<string, any>
416
+ * }
417
+ * | {
418
+ * type: "optgroup",
419
+ * label: string,
420
+ * data?: Record<string, any>,
421
+ * options: Array<{
422
+ * value: string,
423
+ * text: string,
424
+ * selected?: boolean,
425
+ * data?: Record<string, any>
426
+ * }>
427
+ * }
428
+ * >,
429
+ * hasPagination: boolean,
430
+ * page: number,
431
+ * totalPages: number,
432
+ * hasMore: boolean
433
+ * }}
434
+ */
435
+ #parseResponse(data) {
436
+ let items = [];
437
+ let hasPagination = false;
438
+ let page = 0;
439
+ let totalPages = 1;
440
+ let hasMore = false;
441
+
442
+ if (data.object && Array.isArray(data.object)) {
443
+ items = data.object;
444
+ if (typeof data.page !== "undefined") {
445
+ hasPagination = true;
446
+ page = parseInt(data.page) || 0;
447
+ totalPages = parseInt(data.totalPages || data.total_page) || 1;
448
+ hasMore = page < totalPages - 1;
449
+ }
450
+ }
451
+ else if (data.data && Array.isArray(data.data)) {
452
+ items = data.data;
453
+ if (typeof data.page !== "undefined") {
454
+ hasPagination = true;
455
+ page = parseInt(data.page) || 0;
456
+ totalPages = parseInt(data.totalPages || data.total_page) || 1;
457
+ hasMore = data.hasMore ?? (page < totalPages - 1);
458
+ }
459
+ }
460
+ else if (Array.isArray(data)) {
461
+ items = data;
462
+ }
463
+ else if (data.items && Array.isArray(data.items)) {
464
+ items = data.items;
465
+ if (data.pagination) {
466
+ hasPagination = true;
467
+ page = parseInt(data.pagination.page) || 0;
468
+ totalPages = parseInt(data.pagination.totalPages || data.pagination.total_page) || 1;
469
+ hasMore = data.pagination.hasMore ?? (page < totalPages - 1);
470
+ }
471
+ }
472
+
473
+ items = items.map(item => {
474
+ if (item instanceof HTMLOptionElement || item instanceof HTMLOptGroupElement) {
475
+ return item;
476
+ }
477
+
478
+ if (item.type === "optgroup" || item.isGroup || item.group || item.label) {
479
+ return {
480
+ type: "optgroup",
481
+ label: item.label || item.name || item.title || "",
482
+ data: item.data || {},
483
+ options: (item.options || item.items || []).map(opt => ({
484
+ value: opt.value || opt.id || opt.key || "",
485
+ text: opt.text || opt.label || opt.name || opt.title || "",
486
+ selected: opt.selected || false,
487
+ data: opt.data || (opt.imgsrc ? { imgsrc: opt.imgsrc } : {})
488
+ }))
489
+ };
490
+ }
491
+
492
+ let data = item.data || {};
493
+ if (item?.imgsrc) {
494
+ data.imgsrc = item.imgsrc;
495
+ }
496
+
497
+ return {
498
+ type: "option",
499
+ value: item.value || item.id || item.key || "",
500
+ text: item.text || item.label || item.name || item.title || "",
501
+ selected: item.selected || false,
502
+ data: data
503
+ };
504
+ });
505
+
506
+ return {
507
+ items,
508
+ hasPagination,
509
+ page,
510
+ totalPages,
511
+ hasMore
512
+ };
513
+ }
514
+
515
+ /**
516
+ * Applies normalized AJAX results to the underlying <select> element.
517
+ * Optionally keeps previous selections, supports appending, and preserves
518
+ * custom data attributes for both options and optgroups. Emits "options:changed".
519
+ *
520
+ * @param {Array<
521
+ * | HTMLOptionElement
522
+ * | HTMLOptGroupElement
523
+ * | {type:"option", value:string, text:string, selected?:boolean, data?:Record<string, any>}
524
+ * | {type:"optgroup", label:string, data?:Record<string, any>, options:Array<{value:string, text:string, selected?:boolean, data?:Record<string, any>}>}
525
+ * >} items - The normalized list of items to apply.
526
+ * @param {boolean} keepSelected - If true, previously selected values are preserved when possible.
527
+ * @param {boolean} [append=false] - If true, append to existing options; otherwise replace them.
528
+ */
529
+ #applyAjaxResult(items, keepSelected, append = false) {
530
+ const select = this.#select;
531
+
532
+ let oldSelected = [];
533
+ if (keepSelected) {
534
+ oldSelected = Array.from(select.selectedOptions).map(o => o.value);
535
+ }
536
+
537
+ if (!append) {
538
+ select.innerHTML = "";
539
+ }
540
+
541
+ items.forEach(item => {
542
+ if ((item["type"] === "option" || !item["type"]) && item["value"] === "" && item["text"] === "") {
543
+ return;
544
+ }
545
+
546
+ if (item instanceof HTMLOptionElement || item instanceof HTMLOptGroupElement) {
547
+ select.appendChild(item);
548
+ return;
549
+ }
550
+
551
+ if (item.type === "optgroup") {
552
+ const optgroup = document.createElement("optgroup");
553
+ optgroup.label = item.label;
554
+
555
+ if (item.data) {
556
+ Object.keys(item.data).forEach(key => {
557
+ optgroup.dataset[key] = item.data[key];
558
+ });
559
+ }
560
+
561
+ if (item.options && Array.isArray(item.options)) {
562
+ item.options.forEach(opt => {
563
+ const option = document.createElement("option");
564
+ option.value = opt.value;
565
+ option.text = opt.text;
566
+
567
+ if (opt.data) {
568
+ Object.keys(opt.data).forEach(key => {
569
+ option.dataset[key] = opt.data[key];
570
+ });
571
+ }
572
+
573
+ if (opt.selected || (keepSelected && oldSelected.includes(option.value))) {
574
+ option.selected = true;
575
+ }
576
+
577
+ optgroup.appendChild(option);
578
+ });
579
+ }
580
+
581
+ select.appendChild(optgroup);
582
+ }
583
+ else {
584
+ const option = document.createElement("option");
585
+ option.value = item.value;
586
+ option.text = item.text;
587
+
588
+ if (item.data) {
589
+ Object.keys(item.data).forEach(key => {
590
+ option.dataset[key] = item.data[key];
591
+ });
592
+ }
593
+
594
+ if (item.selected || (keepSelected && oldSelected.includes(option.value))) {
595
+ option.selected = true;
596
+ }
597
+
598
+ select.appendChild(option);
599
+ }
600
+ });
601
+
602
+ select.dispatchEvent(new CustomEvent("options:changed"));
603
+ }
522
604
  }