selective-ui 1.0.2
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/LICENSE +21 -0
- package/README.md +2 -0
- package/dist/selective-ui.css +569 -0
- package/dist/selective-ui.css.map +1 -0
- package/dist/selective-ui.esm.js +6101 -0
- package/dist/selective-ui.esm.js.map +1 -0
- package/dist/selective-ui.esm.min.js +1 -0
- package/dist/selective-ui.esm.min.js.br +0 -0
- package/dist/selective-ui.min.css +1 -0
- package/dist/selective-ui.min.css.br +0 -0
- package/dist/selective-ui.min.js +2 -0
- package/dist/selective-ui.min.js.br +0 -0
- package/dist/selective-ui.umd.js +6115 -0
- package/dist/selective-ui.umd.js.map +1 -0
- package/package.json +68 -0
- package/src/css/components/accessorybox.css +64 -0
- package/src/css/components/directive.css +20 -0
- package/src/css/components/empty-state.css +26 -0
- package/src/css/components/loading-state.css +26 -0
- package/src/css/components/optgroup.css +62 -0
- package/src/css/components/option-handle.css +34 -0
- package/src/css/components/option.css +130 -0
- package/src/css/components/placeholder.css +15 -0
- package/src/css/components/popup.css +39 -0
- package/src/css/components/searchbox.css +29 -0
- package/src/css/components/selectbox.css +54 -0
- package/src/css/index.css +75 -0
- package/src/js/adapter/mixed-adapter.js +435 -0
- package/src/js/components/accessorybox.js +125 -0
- package/src/js/components/directive.js +38 -0
- package/src/js/components/empty-state.js +68 -0
- package/src/js/components/loading-state.js +60 -0
- package/src/js/components/option-handle.js +114 -0
- package/src/js/components/placeholder.js +57 -0
- package/src/js/components/popup.js +471 -0
- package/src/js/components/searchbox.js +168 -0
- package/src/js/components/selectbox.js +693 -0
- package/src/js/core/base/adapter.js +163 -0
- package/src/js/core/base/model.js +59 -0
- package/src/js/core/base/recyclerview.js +83 -0
- package/src/js/core/base/view.js +62 -0
- package/src/js/core/model-manager.js +286 -0
- package/src/js/core/search-controller.js +522 -0
- package/src/js/index.js +137 -0
- package/src/js/models/group-model.js +143 -0
- package/src/js/models/option-model.js +237 -0
- package/src/js/services/dataset-observer.js +73 -0
- package/src/js/services/ea-observer.js +88 -0
- package/src/js/services/effector.js +404 -0
- package/src/js/services/refresher.js +40 -0
- package/src/js/services/resize-observer.js +152 -0
- package/src/js/services/select-observer.js +61 -0
- package/src/js/types/adapter.type.js +33 -0
- package/src/js/types/effector.type.js +24 -0
- package/src/js/types/ievents.type.js +11 -0
- package/src/js/types/libs.type.js +28 -0
- package/src/js/types/model.type.js +11 -0
- package/src/js/types/recyclerview.type.js +12 -0
- package/src/js/types/resize-observer.type.js +19 -0
- package/src/js/types/view.group.type.js +13 -0
- package/src/js/types/view.option.type.js +15 -0
- package/src/js/types/view.type.js +11 -0
- package/src/js/utils/guard.js +47 -0
- package/src/js/utils/ievents.js +83 -0
- package/src/js/utils/istorage.js +61 -0
- package/src/js/utils/libs.js +619 -0
- package/src/js/utils/selective.js +386 -0
- package/src/js/views/group-view.js +103 -0
- package/src/js/views/option-view.js +153 -0
|
@@ -0,0 +1,522 @@
|
|
|
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
|
+
}
|
|
522
|
+
}
|
package/src/js/index.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import "../css/index.css";
|
|
2
|
+
import "../css/components/selectbox.css";
|
|
3
|
+
import "../css/components/placeholder.css";
|
|
4
|
+
import "../css/components/directive.css";
|
|
5
|
+
import "../css/components/empty-state.css";
|
|
6
|
+
import "../css/components/loading-state.css";
|
|
7
|
+
import "../css/components/optgroup.css";
|
|
8
|
+
import "../css/components/popup.css";
|
|
9
|
+
import "../css/components/searchbox.css";
|
|
10
|
+
import "../css/components/option-handle.css";
|
|
11
|
+
import "../css/components/option.css";
|
|
12
|
+
import "../css/components/accessorybox.css";
|
|
13
|
+
|
|
14
|
+
import "./types/adapter.type";
|
|
15
|
+
import "./types/effector.type";
|
|
16
|
+
import "./types/ievents.type";
|
|
17
|
+
import "./types/libs.type";
|
|
18
|
+
import "./types/model.type";
|
|
19
|
+
import "./types/recyclerview.type";
|
|
20
|
+
import "./types/resize-observer.type";
|
|
21
|
+
import "./types/view.option.type";
|
|
22
|
+
import "./types/view.type";
|
|
23
|
+
|
|
24
|
+
import { Selective } from "./utils/selective";
|
|
25
|
+
import { checkDuplicate, markLoaded } from "./utils/guard";
|
|
26
|
+
import { Libs } from "./utils/libs";
|
|
27
|
+
import { Effector } from "./services/effector";
|
|
28
|
+
|
|
29
|
+
export const version = "1.0.2";
|
|
30
|
+
export const name = "SelectiveUI";
|
|
31
|
+
|
|
32
|
+
const alreadyLoaded = checkDuplicate(name);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Enhances all <select> elements matching the query with Selective UI.
|
|
36
|
+
* If a prior global instance is already loaded, proxies the call to it; otherwise uses local Selective.bind.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} query - CSS selector for target <select> elements.
|
|
39
|
+
* @param {object} [options={}] - Configuration overrides merged with defaults.
|
|
40
|
+
*/
|
|
41
|
+
export function bind(query, options = {}) {
|
|
42
|
+
if (alreadyLoaded && typeof window !== "undefined" && window[name]) {
|
|
43
|
+
return window[name].bind(query, options);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
Selective.bind(query, options);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Retrieves the dynamic action API for bound instances matching the query.
|
|
51
|
+
* Proxies to an already-loaded global instance if present; otherwise uses local Selective.find.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} query - CSS selector identifying bound instances.
|
|
54
|
+
* @returns {{isEmpty:boolean} & Record<string, unknown>} - Action API; {isEmpty:true} if none found.
|
|
55
|
+
*/
|
|
56
|
+
export function find(query) {
|
|
57
|
+
if (alreadyLoaded && typeof window !== "undefined" && window[name]) {
|
|
58
|
+
return window[name].find(query);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return Selective.find(query);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Destroys Selective instances associated with the given query.
|
|
66
|
+
* Proxies to a global loaded instance if available; otherwise uses local Selective.destroy.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} query - CSS selector identifying instances to tear down.
|
|
69
|
+
* @returns {void}
|
|
70
|
+
*/
|
|
71
|
+
export function destroy(query) {
|
|
72
|
+
if (alreadyLoaded && typeof window !== "undefined" && window[name]) {
|
|
73
|
+
return window[name].destroy(query);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return Selective.destroy(query);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Rebinds Selective for the given query by destroying existing instances and binding anew.
|
|
81
|
+
* Proxies to a global loaded instance if available; otherwise uses local Selective.rebind.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} query - CSS selector for target <select> elements.
|
|
84
|
+
* @param {object} [options={}] - Configuration overrides for the new binding.
|
|
85
|
+
* @returns {void}
|
|
86
|
+
*/
|
|
87
|
+
export function rebind(query, options = {}) {
|
|
88
|
+
if (alreadyLoaded && typeof window !== "undefined" && window[name]) {
|
|
89
|
+
return window[name].rebind(query, options);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return Selective.rebind(query, options);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Returns an effector instance for a given element, enabling expand/collapse/resize animations.
|
|
97
|
+
* Proxies to a global loaded instance if available; otherwise constructs a local Effector.
|
|
98
|
+
*
|
|
99
|
+
* @param {string|HTMLElement} element - CSS selector or element to control.
|
|
100
|
+
* @returns {EffectorInterface} - The effector utility bound to the element.
|
|
101
|
+
*/
|
|
102
|
+
export function effector(element) {
|
|
103
|
+
if (alreadyLoaded && typeof window !== "undefined" && window[name]) {
|
|
104
|
+
return window[name].effector(element);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return Effector(element);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!alreadyLoaded) {
|
|
111
|
+
let initialized = false;
|
|
112
|
+
|
|
113
|
+
function init() {
|
|
114
|
+
if (initialized) return;
|
|
115
|
+
initialized = true;
|
|
116
|
+
|
|
117
|
+
document.addEventListener("mousedown", () => {
|
|
118
|
+
const sels = Libs.getBindedCommand();
|
|
119
|
+
if (sels.length > 0) {
|
|
120
|
+
const optanObj = Selective.find(sels.join(", "));
|
|
121
|
+
if (!optanObj.isEmpty) {
|
|
122
|
+
optanObj.close();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
Selective.Observer();
|
|
128
|
+
|
|
129
|
+
markLoaded(name, version, { bind, find, destroy, rebind, effector, version });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (document.readyState === "loading") {
|
|
133
|
+
document.addEventListener("DOMContentLoaded", init);
|
|
134
|
+
} else {
|
|
135
|
+
init();
|
|
136
|
+
}
|
|
137
|
+
}
|