@referralgps/selectra 1.0.0
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 +203 -0
- package/README.md +346 -0
- package/dist/selectra.css +1 -0
- package/dist/selectra.es.js +1318 -0
- package/dist/selectra.es.js.map +1 -0
- package/dist/selectra.iife.js +32 -0
- package/dist/selectra.iife.js.map +1 -0
- package/dist/selectra.umd.js +32 -0
- package/dist/selectra.umd.js.map +1 -0
- package/package.json +64 -0
- package/src-alpine/README.md +346 -0
- package/src-alpine/index.d.ts +217 -0
- package/src-alpine/index.js +70 -0
- package/src-alpine/plugins/index.js +219 -0
- package/src-alpine/selectize.js +1022 -0
- package/src-alpine/sifter.js +270 -0
- package/src-alpine/styles/selectra.css +277 -0
- package/src-alpine/utils.js +181 -0
|
@@ -0,0 +1,1022 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Selectra Alpine.js Component
|
|
3
|
+
*
|
|
4
|
+
* A powerful, extensible select/tagging component built with Alpine.js and Tailwind CSS.
|
|
5
|
+
* Drop-in replacement for selectize.js without jQuery dependency.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import Sifter from './sifter.js';
|
|
9
|
+
import {
|
|
10
|
+
escapeHtml,
|
|
11
|
+
debounce,
|
|
12
|
+
uid,
|
|
13
|
+
hashKey,
|
|
14
|
+
highlight,
|
|
15
|
+
autoGrow,
|
|
16
|
+
readSelectOptions,
|
|
17
|
+
isSelectElement,
|
|
18
|
+
isRtl,
|
|
19
|
+
} from './utils.js';
|
|
20
|
+
|
|
21
|
+
/** Default configuration */
|
|
22
|
+
const DEFAULTS = {
|
|
23
|
+
delimiter: ',',
|
|
24
|
+
splitOn: null,
|
|
25
|
+
persist: true,
|
|
26
|
+
diacritics: true,
|
|
27
|
+
create: false,
|
|
28
|
+
showAddOptionOnCreate: true,
|
|
29
|
+
createOnBlur: false,
|
|
30
|
+
createFilter: null,
|
|
31
|
+
highlight: true,
|
|
32
|
+
openOnFocus: true,
|
|
33
|
+
maxOptions: 1000,
|
|
34
|
+
maxItems: null,
|
|
35
|
+
hideSelected: null,
|
|
36
|
+
selectOnTab: true,
|
|
37
|
+
preload: false,
|
|
38
|
+
allowEmptyOption: false,
|
|
39
|
+
closeAfterSelect: false,
|
|
40
|
+
loadThrottle: 300,
|
|
41
|
+
loadingClass: 'loading',
|
|
42
|
+
placeholder: '',
|
|
43
|
+
mode: null, // 'single' | 'multi' — auto-detected
|
|
44
|
+
search: true,
|
|
45
|
+
showArrow: true,
|
|
46
|
+
valueField: 'value',
|
|
47
|
+
labelField: 'text',
|
|
48
|
+
disabledField: 'disabled',
|
|
49
|
+
optgroupField: 'optgroup',
|
|
50
|
+
optgroupLabelField: 'label',
|
|
51
|
+
optgroupValueField: 'value',
|
|
52
|
+
sortField: '$order',
|
|
53
|
+
searchField: ['text'],
|
|
54
|
+
searchConjunction: 'and',
|
|
55
|
+
respectWordBoundaries: false,
|
|
56
|
+
normalize: true,
|
|
57
|
+
plugins: [],
|
|
58
|
+
|
|
59
|
+
// Render functions — return HTML strings
|
|
60
|
+
render: {
|
|
61
|
+
option: null,
|
|
62
|
+
item: null,
|
|
63
|
+
optionCreate: null,
|
|
64
|
+
optgroupHeader: null,
|
|
65
|
+
noResults: null,
|
|
66
|
+
loading: null,
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
// Callbacks
|
|
70
|
+
load: null,
|
|
71
|
+
score: null,
|
|
72
|
+
onChange: null,
|
|
73
|
+
onItemAdd: null,
|
|
74
|
+
onItemRemove: null,
|
|
75
|
+
onClear: null,
|
|
76
|
+
onOptionAdd: null,
|
|
77
|
+
onOptionRemove: null,
|
|
78
|
+
onDropdownOpen: null,
|
|
79
|
+
onDropdownClose: null,
|
|
80
|
+
onType: null,
|
|
81
|
+
onFocus: null,
|
|
82
|
+
onBlur: null,
|
|
83
|
+
onInitialize: null,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** Registered plugins */
|
|
87
|
+
const pluginRegistry = {};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Create the selectize Alpine.js component
|
|
91
|
+
*/
|
|
92
|
+
export function createSelectizeComponent(userConfig = {}) {
|
|
93
|
+
return () => ({
|
|
94
|
+
// ── Reactive state ──────────────────────────────────────
|
|
95
|
+
isOpen: false,
|
|
96
|
+
isFocused: false,
|
|
97
|
+
isDisabled: false,
|
|
98
|
+
isLocked: false,
|
|
99
|
+
isLoading: false,
|
|
100
|
+
isInvalid: false,
|
|
101
|
+
|
|
102
|
+
query: '',
|
|
103
|
+
activeIndex: -1,
|
|
104
|
+
caretPos: 0,
|
|
105
|
+
|
|
106
|
+
items: [], // selected values (strings)
|
|
107
|
+
options: {}, // { [value]: { value, text, ... } }
|
|
108
|
+
optgroups: {}, // { [id]: { value, label, ... } }
|
|
109
|
+
userOptions: {}, // user-created option values
|
|
110
|
+
optionOrder: [], // maintains insertion order
|
|
111
|
+
|
|
112
|
+
loadedSearches: {},
|
|
113
|
+
lastQuery: '',
|
|
114
|
+
|
|
115
|
+
// Internal
|
|
116
|
+
_config: {},
|
|
117
|
+
_sifter: null,
|
|
118
|
+
_sourceEl: null,
|
|
119
|
+
_id: '',
|
|
120
|
+
_rtl: false,
|
|
121
|
+
_plugins: [],
|
|
122
|
+
_renderCache: {},
|
|
123
|
+
|
|
124
|
+
// ── Computed properties ─────────────────────────────────
|
|
125
|
+
get config() {
|
|
126
|
+
return this._config;
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
get isMultiple() {
|
|
130
|
+
return this._config.mode === 'multi';
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
get isSingle() {
|
|
134
|
+
return this._config.mode === 'single';
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
get isFull() {
|
|
138
|
+
return this._config.maxItems !== null && this.items.length >= this._config.maxItems;
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
get hasOptions() {
|
|
142
|
+
return Object.keys(this.options).length > 0;
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
get canCreate() {
|
|
146
|
+
if (!this._config.create) return false;
|
|
147
|
+
if (!this.query.trim()) return false;
|
|
148
|
+
if (this.isFull) return false;
|
|
149
|
+
if (this._config.createFilter) {
|
|
150
|
+
const filter = this._config.createFilter;
|
|
151
|
+
if (typeof filter === 'function') return filter(this.query);
|
|
152
|
+
if (filter instanceof RegExp) return filter.test(this.query);
|
|
153
|
+
if (typeof filter === 'string') return new RegExp(filter).test(this.query);
|
|
154
|
+
}
|
|
155
|
+
// Check if already exists
|
|
156
|
+
const existing = Object.values(this.options).find(
|
|
157
|
+
(o) => o[this._config.labelField]?.toLowerCase() === this.query.toLowerCase()
|
|
158
|
+
);
|
|
159
|
+
return !existing;
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
get selectedItems() {
|
|
163
|
+
return this.items
|
|
164
|
+
.map((val) => this.options[hashKey(val)])
|
|
165
|
+
.filter(Boolean);
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
get filteredOptions() {
|
|
169
|
+
return this._getFilteredOptions();
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
get placeholderText() {
|
|
173
|
+
if (this.items.length > 0 && this.isSingle) return '';
|
|
174
|
+
return this._config.placeholder || '';
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
get currentValueText() {
|
|
178
|
+
if (!this.isSingle || !this.items.length) return '';
|
|
179
|
+
const opt = this.options[hashKey(this.items[0])];
|
|
180
|
+
return opt ? opt[this._config.labelField] : '';
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
// ── Lifecycle ───────────────────────────────────────────
|
|
184
|
+
init() {
|
|
185
|
+
this._id = uid();
|
|
186
|
+
this._config = { ...DEFAULTS, ...userConfig };
|
|
187
|
+
|
|
188
|
+
// Detect source element
|
|
189
|
+
this._sourceEl = this.$el.querySelector('select, input[type="text"], input[type="hidden"]');
|
|
190
|
+
|
|
191
|
+
// Read options from <select> if present
|
|
192
|
+
if (this._sourceEl && isSelectElement(this._sourceEl)) {
|
|
193
|
+
const parsed = readSelectOptions(this._sourceEl);
|
|
194
|
+
|
|
195
|
+
// Merge parsed options with config options
|
|
196
|
+
const configOptions = this._config.options || [];
|
|
197
|
+
const allOptions = [...parsed.options, ...configOptions];
|
|
198
|
+
this._registerOptions(allOptions);
|
|
199
|
+
|
|
200
|
+
// Register optgroups
|
|
201
|
+
for (const og of parsed.optgroups) {
|
|
202
|
+
this.optgroups[og.value] = og;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Set initial value
|
|
206
|
+
if (parsed.selectedValues.length) {
|
|
207
|
+
this.items = [...parsed.selectedValues];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Detect mode from select element
|
|
211
|
+
if (!userConfig.mode) {
|
|
212
|
+
this._config.mode = this._sourceEl.multiple ? 'multi' : 'single';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Read attributes
|
|
216
|
+
if (this._sourceEl.hasAttribute('required')) this.isInvalid = !this.items.length;
|
|
217
|
+
if (this._sourceEl.disabled) this.isDisabled = true;
|
|
218
|
+
if (this._sourceEl.placeholder) this._config.placeholder = this._sourceEl.placeholder;
|
|
219
|
+
|
|
220
|
+
// Hide source element
|
|
221
|
+
this._sourceEl.style.display = 'none';
|
|
222
|
+
this._sourceEl.setAttribute('tabindex', '-1');
|
|
223
|
+
|
|
224
|
+
// RTL detection
|
|
225
|
+
this._rtl = isRtl(this._sourceEl);
|
|
226
|
+
} else {
|
|
227
|
+
// Config-only mode
|
|
228
|
+
const configOptions = this._config.options || [];
|
|
229
|
+
this._registerOptions(configOptions);
|
|
230
|
+
|
|
231
|
+
// Register optgroups from config
|
|
232
|
+
if (this._config.optgroups) {
|
|
233
|
+
for (const og of this._config.optgroups) {
|
|
234
|
+
this.optgroups[og[this._config.optgroupValueField]] = og;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Set initial items
|
|
239
|
+
if (this._config.items) {
|
|
240
|
+
this.items = [...this._config.items];
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Mode detection
|
|
245
|
+
if (!this._config.mode) {
|
|
246
|
+
this._config.mode = this._config.maxItems === 1 ? 'single' : 'multi';
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Set maxItems=1 for single mode
|
|
250
|
+
if (this._config.mode === 'single') {
|
|
251
|
+
this._config.maxItems = 1;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Default hideSelected
|
|
255
|
+
if (this._config.hideSelected === null) {
|
|
256
|
+
this._config.hideSelected = this._config.mode === 'multi';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Initialize search engine
|
|
260
|
+
this._sifter = new Sifter(this.options, { diacritics: this._config.diacritics });
|
|
261
|
+
|
|
262
|
+
// Initialize plugins
|
|
263
|
+
this._initPlugins();
|
|
264
|
+
|
|
265
|
+
// Set up the load function debouncing
|
|
266
|
+
if (this._config.load && this._config.loadThrottle) {
|
|
267
|
+
this._debouncedLoad = debounce(this._performLoad.bind(this), this._config.loadThrottle);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Preload if configured
|
|
271
|
+
if (this._config.preload) {
|
|
272
|
+
this.$nextTick(() => {
|
|
273
|
+
if (this._config.preload === 'focus') {
|
|
274
|
+
// Will load on focus
|
|
275
|
+
} else {
|
|
276
|
+
this._performLoad('');
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Trigger onInitialize
|
|
282
|
+
this._trigger('onInitialize');
|
|
283
|
+
|
|
284
|
+
// Watch for outside clicks
|
|
285
|
+
this._onClickOutside = (e) => {
|
|
286
|
+
if (!this.$el.contains(e.target)) {
|
|
287
|
+
this.close();
|
|
288
|
+
this.blur();
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
document.addEventListener('mousedown', this._onClickOutside);
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
destroy() {
|
|
295
|
+
document.removeEventListener('mousedown', this._onClickOutside);
|
|
296
|
+
if (this._sourceEl) {
|
|
297
|
+
this._sourceEl.style.display = '';
|
|
298
|
+
this._sourceEl.removeAttribute('tabindex');
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
// ── Plugin System ───────────────────────────────────────
|
|
303
|
+
_initPlugins() {
|
|
304
|
+
const plugins = this._config.plugins || [];
|
|
305
|
+
for (const plugin of plugins) {
|
|
306
|
+
const name = typeof plugin === 'string' ? plugin : plugin.name;
|
|
307
|
+
const opts = typeof plugin === 'string' ? {} : (plugin.options || {});
|
|
308
|
+
if (pluginRegistry[name]) {
|
|
309
|
+
pluginRegistry[name].call(this, opts);
|
|
310
|
+
this._plugins.push(name);
|
|
311
|
+
} else {
|
|
312
|
+
console.warn(`[selectize] Plugin "${name}" not found.`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
|
|
317
|
+
// ── Option Management ───────────────────────────────────
|
|
318
|
+
_registerOptions(optionsList) {
|
|
319
|
+
for (const opt of optionsList) {
|
|
320
|
+
this.addOption(opt, true);
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
|
|
324
|
+
addOption(data, silent = false) {
|
|
325
|
+
if (Array.isArray(data)) {
|
|
326
|
+
for (const item of data) this.addOption(item, silent);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const key = hashKey(data[this._config.valueField]);
|
|
330
|
+
if (key === null || this.options[key]) return;
|
|
331
|
+
|
|
332
|
+
data.$order = data.$order || ++this._orderCounter || (this._orderCounter = 1);
|
|
333
|
+
this.options[key] = data;
|
|
334
|
+
this.optionOrder.push(key);
|
|
335
|
+
|
|
336
|
+
// Update sifter
|
|
337
|
+
if (this._sifter) this._sifter.items = this.options;
|
|
338
|
+
this._clearRenderCache();
|
|
339
|
+
if (!silent) this._trigger('onOptionAdd', key, data);
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
updateOption(value, data) {
|
|
343
|
+
const key = hashKey(value);
|
|
344
|
+
if (!key || !this.options[key]) return;
|
|
345
|
+
|
|
346
|
+
const newKey = hashKey(data[this._config.valueField]);
|
|
347
|
+
data.$order = this.options[key].$order;
|
|
348
|
+
this.options[newKey] = data;
|
|
349
|
+
|
|
350
|
+
if (key !== newKey) {
|
|
351
|
+
delete this.options[key];
|
|
352
|
+
const idx = this.optionOrder.indexOf(key);
|
|
353
|
+
if (idx !== -1) this.optionOrder[idx] = newKey;
|
|
354
|
+
|
|
355
|
+
// Update items
|
|
356
|
+
const itemIdx = this.items.indexOf(key);
|
|
357
|
+
if (itemIdx !== -1) this.items[itemIdx] = newKey;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (this._sifter) this._sifter.items = this.options;
|
|
361
|
+
this._clearRenderCache();
|
|
362
|
+
},
|
|
363
|
+
|
|
364
|
+
removeOption(value) {
|
|
365
|
+
const key = hashKey(value);
|
|
366
|
+
if (!key) return;
|
|
367
|
+
delete this.options[key];
|
|
368
|
+
delete this.userOptions[key];
|
|
369
|
+
const idx = this.optionOrder.indexOf(key);
|
|
370
|
+
if (idx !== -1) this.optionOrder.splice(idx, 1);
|
|
371
|
+
|
|
372
|
+
this.items = this.items.filter((v) => v !== key);
|
|
373
|
+
if (this._sifter) this._sifter.items = this.options;
|
|
374
|
+
this._clearRenderCache();
|
|
375
|
+
this._trigger('onOptionRemove', key);
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
clearOptions() {
|
|
379
|
+
// Keep selected items' options
|
|
380
|
+
const keep = {};
|
|
381
|
+
for (const val of this.items) {
|
|
382
|
+
if (this.options[val]) keep[val] = this.options[val];
|
|
383
|
+
}
|
|
384
|
+
this.options = keep;
|
|
385
|
+
this.optionOrder = Object.keys(keep);
|
|
386
|
+
this.userOptions = {};
|
|
387
|
+
if (this._sifter) this._sifter.items = this.options;
|
|
388
|
+
this._clearRenderCache();
|
|
389
|
+
},
|
|
390
|
+
|
|
391
|
+
getOption(value) {
|
|
392
|
+
return this.options[hashKey(value)] || null;
|
|
393
|
+
},
|
|
394
|
+
|
|
395
|
+
// ── Item (Selection) Management ─────────────────────────
|
|
396
|
+
addItem(value, silent = false) {
|
|
397
|
+
const key = hashKey(value);
|
|
398
|
+
if (!key || !this.options[key]) return;
|
|
399
|
+
if (this.items.includes(key)) return;
|
|
400
|
+
if (this.isFull) return;
|
|
401
|
+
|
|
402
|
+
// For single select, replace current
|
|
403
|
+
if (this.isSingle && this.items.length) {
|
|
404
|
+
this.removeItem(this.items[0], true);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
this.items.push(key);
|
|
408
|
+
this.caretPos = this.items.length;
|
|
409
|
+
|
|
410
|
+
this._syncSourceElement();
|
|
411
|
+
this._clearRenderCache();
|
|
412
|
+
this.query = '';
|
|
413
|
+
|
|
414
|
+
if (this._config.closeAfterSelect || this.isSingle) {
|
|
415
|
+
this.close();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (this.isFull) {
|
|
419
|
+
this.close();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (!silent) {
|
|
423
|
+
this._trigger('onItemAdd', key, this.options[key]);
|
|
424
|
+
this._trigger('onChange', this.getValue());
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
|
|
428
|
+
removeItem(value, silent = false) {
|
|
429
|
+
const key = hashKey(value);
|
|
430
|
+
const idx = this.items.indexOf(key);
|
|
431
|
+
if (idx === -1) return;
|
|
432
|
+
|
|
433
|
+
this.items.splice(idx, 1);
|
|
434
|
+
if (this.caretPos > this.items.length) {
|
|
435
|
+
this.caretPos = this.items.length;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
this._syncSourceElement();
|
|
439
|
+
this._clearRenderCache();
|
|
440
|
+
|
|
441
|
+
if (!silent) {
|
|
442
|
+
this._trigger('onItemRemove', key);
|
|
443
|
+
this._trigger('onChange', this.getValue());
|
|
444
|
+
}
|
|
445
|
+
},
|
|
446
|
+
|
|
447
|
+
clear(silent = false) {
|
|
448
|
+
if (!this.items.length) return;
|
|
449
|
+
this.items = [];
|
|
450
|
+
this.caretPos = 0;
|
|
451
|
+
this._syncSourceElement();
|
|
452
|
+
this._clearRenderCache();
|
|
453
|
+
|
|
454
|
+
if (!silent) {
|
|
455
|
+
this._trigger('onClear');
|
|
456
|
+
this._trigger('onChange', this.getValue());
|
|
457
|
+
}
|
|
458
|
+
},
|
|
459
|
+
|
|
460
|
+
getValue() {
|
|
461
|
+
if (this.isSingle) {
|
|
462
|
+
return this.items.length ? this.items[0] : '';
|
|
463
|
+
}
|
|
464
|
+
return [...this.items];
|
|
465
|
+
},
|
|
466
|
+
|
|
467
|
+
setValue(value, silent = false) {
|
|
468
|
+
this.clear(true);
|
|
469
|
+
const values = Array.isArray(value) ? value : [value];
|
|
470
|
+
for (const v of values) {
|
|
471
|
+
if (v !== '' && v !== null && v !== undefined) {
|
|
472
|
+
this.addItem(v, true);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (!silent) {
|
|
476
|
+
this._trigger('onChange', this.getValue());
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
|
|
480
|
+
// ── Create Item ─────────────────────────────────────────
|
|
481
|
+
createItem(input = null) {
|
|
482
|
+
const val = input !== null ? input : this.query;
|
|
483
|
+
if (!val.trim()) return;
|
|
484
|
+
if (!this._config.create) return;
|
|
485
|
+
|
|
486
|
+
const createFn = this._config.create;
|
|
487
|
+
let data;
|
|
488
|
+
|
|
489
|
+
if (typeof createFn === 'function') {
|
|
490
|
+
data = createFn(val, (result) => {
|
|
491
|
+
if (result) {
|
|
492
|
+
this.addOption(result);
|
|
493
|
+
this.addItem(result[this._config.valueField]);
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
// If create returns data synchronously, use it
|
|
497
|
+
if (data && typeof data === 'object') {
|
|
498
|
+
this.addOption(data);
|
|
499
|
+
this.addItem(data[this._config.valueField]);
|
|
500
|
+
}
|
|
501
|
+
} else {
|
|
502
|
+
// Default creation: use input as both value and label
|
|
503
|
+
data = {};
|
|
504
|
+
data[this._config.valueField] = val;
|
|
505
|
+
data[this._config.labelField] = val;
|
|
506
|
+
this.addOption(data);
|
|
507
|
+
this.addItem(val);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
this.query = '';
|
|
511
|
+
this._clearRenderCache();
|
|
512
|
+
},
|
|
513
|
+
|
|
514
|
+
// ── Search ──────────────────────────────────────────────
|
|
515
|
+
_getFilteredOptions() {
|
|
516
|
+
const config = this._config;
|
|
517
|
+
if (!this._sifter) return [];
|
|
518
|
+
|
|
519
|
+
// Prepare search fields
|
|
520
|
+
const searchFields = Array.isArray(config.searchField)
|
|
521
|
+
? config.searchField
|
|
522
|
+
: [config.searchField];
|
|
523
|
+
|
|
524
|
+
// Prepare sort
|
|
525
|
+
let sort;
|
|
526
|
+
if (config.sortField) {
|
|
527
|
+
if (typeof config.sortField === 'string') {
|
|
528
|
+
sort = [{ field: config.sortField, direction: 'asc' }];
|
|
529
|
+
} else if (Array.isArray(config.sortField)) {
|
|
530
|
+
sort = config.sortField;
|
|
531
|
+
} else {
|
|
532
|
+
sort = [config.sortField];
|
|
533
|
+
}
|
|
534
|
+
} else {
|
|
535
|
+
sort = [{ field: '$order', direction: 'asc' }];
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
let q = this.query;
|
|
539
|
+
if (config.normalize && q) {
|
|
540
|
+
q = q.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const searchOptions = {
|
|
544
|
+
fields: searchFields,
|
|
545
|
+
conjunction: config.searchConjunction,
|
|
546
|
+
sort,
|
|
547
|
+
nesting: searchFields.some((f) => f.includes('.')),
|
|
548
|
+
respect_word_boundaries: config.respectWordBoundaries,
|
|
549
|
+
limit: config.maxOptions,
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
if (config.score) {
|
|
553
|
+
searchOptions.score = config.score;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const results = this._sifter.search(q, searchOptions);
|
|
557
|
+
|
|
558
|
+
let filtered = results.items
|
|
559
|
+
.map((item) => {
|
|
560
|
+
const opt = this.options[item.id];
|
|
561
|
+
return opt ? { ...opt, _score: item.score } : null;
|
|
562
|
+
})
|
|
563
|
+
.filter(Boolean);
|
|
564
|
+
|
|
565
|
+
// Hide selected
|
|
566
|
+
if (config.hideSelected) {
|
|
567
|
+
filtered = filtered.filter(
|
|
568
|
+
(opt) => !this.items.includes(hashKey(opt[config.valueField]))
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Filter disabled
|
|
573
|
+
filtered = filtered.filter((opt) => !opt[config.disabledField]);
|
|
574
|
+
|
|
575
|
+
return filtered;
|
|
576
|
+
},
|
|
577
|
+
|
|
578
|
+
// ── Dropdown Control ────────────────────────────────────
|
|
579
|
+
open() {
|
|
580
|
+
if (this.isOpen || this.isDisabled || this.isLocked) return;
|
|
581
|
+
this.isOpen = true;
|
|
582
|
+
this.activeIndex = this._config.setFirstOptionActive ? 0 : -1;
|
|
583
|
+
this._trigger('onDropdownOpen');
|
|
584
|
+
|
|
585
|
+
this.$nextTick(() => {
|
|
586
|
+
this._scrollToActive();
|
|
587
|
+
});
|
|
588
|
+
},
|
|
589
|
+
|
|
590
|
+
close() {
|
|
591
|
+
if (!this.isOpen) return;
|
|
592
|
+
this.isOpen = false;
|
|
593
|
+
this.activeIndex = -1;
|
|
594
|
+
this._trigger('onDropdownClose');
|
|
595
|
+
},
|
|
596
|
+
|
|
597
|
+
toggle() {
|
|
598
|
+
this.isOpen ? this.close() : this.open();
|
|
599
|
+
},
|
|
600
|
+
|
|
601
|
+
// ── Focus / Blur ────────────────────────────────────────
|
|
602
|
+
focus() {
|
|
603
|
+
if (this.isDisabled) return;
|
|
604
|
+
this.isFocused = true;
|
|
605
|
+
|
|
606
|
+
const input = this.$refs.searchInput;
|
|
607
|
+
if (input) {
|
|
608
|
+
this.$nextTick(() => input.focus());
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (this._config.openOnFocus) {
|
|
612
|
+
this.open();
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (this._config.preload === 'focus' && !this.loadedSearches['']) {
|
|
616
|
+
this._performLoad('');
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
this._trigger('onFocus');
|
|
620
|
+
},
|
|
621
|
+
|
|
622
|
+
blur() {
|
|
623
|
+
if (!this.isFocused) return;
|
|
624
|
+
this.isFocused = false;
|
|
625
|
+
|
|
626
|
+
if (this._config.createOnBlur && this.query.trim() && this.canCreate) {
|
|
627
|
+
this.createItem();
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
this.close();
|
|
631
|
+
this._trigger('onBlur');
|
|
632
|
+
},
|
|
633
|
+
|
|
634
|
+
// ── Keyboard Navigation ─────────────────────────────────
|
|
635
|
+
onKeyDown(e) {
|
|
636
|
+
if (this.isDisabled || this.isLocked) return;
|
|
637
|
+
|
|
638
|
+
const opts = this.filteredOptions;
|
|
639
|
+
const canCreateNow = this.canCreate;
|
|
640
|
+
const totalItems = opts.length + (canCreateNow ? 1 : 0);
|
|
641
|
+
|
|
642
|
+
switch (e.key) {
|
|
643
|
+
case 'ArrowDown':
|
|
644
|
+
e.preventDefault();
|
|
645
|
+
if (!this.isOpen) {
|
|
646
|
+
this.open();
|
|
647
|
+
} else {
|
|
648
|
+
this.activeIndex = Math.min(this.activeIndex + 1, totalItems - 1);
|
|
649
|
+
this._scrollToActive();
|
|
650
|
+
}
|
|
651
|
+
break;
|
|
652
|
+
|
|
653
|
+
case 'ArrowUp':
|
|
654
|
+
e.preventDefault();
|
|
655
|
+
if (this.isOpen) {
|
|
656
|
+
this.activeIndex = Math.max(this.activeIndex - 1, 0);
|
|
657
|
+
this._scrollToActive();
|
|
658
|
+
}
|
|
659
|
+
break;
|
|
660
|
+
|
|
661
|
+
case 'Enter':
|
|
662
|
+
e.preventDefault();
|
|
663
|
+
if (this.isOpen && this.activeIndex >= 0) {
|
|
664
|
+
if (this.activeIndex < opts.length) {
|
|
665
|
+
this.selectOption(opts[this.activeIndex]);
|
|
666
|
+
} else if (canCreateNow) {
|
|
667
|
+
this.createItem();
|
|
668
|
+
}
|
|
669
|
+
} else if (!this.isOpen) {
|
|
670
|
+
this.open();
|
|
671
|
+
}
|
|
672
|
+
break;
|
|
673
|
+
|
|
674
|
+
case 'Escape':
|
|
675
|
+
e.preventDefault();
|
|
676
|
+
this.close();
|
|
677
|
+
break;
|
|
678
|
+
|
|
679
|
+
case 'Tab':
|
|
680
|
+
if (this.isOpen && this._config.selectOnTab && this.activeIndex >= 0) {
|
|
681
|
+
e.preventDefault();
|
|
682
|
+
if (this.activeIndex < opts.length) {
|
|
683
|
+
this.selectOption(opts[this.activeIndex]);
|
|
684
|
+
} else if (canCreateNow) {
|
|
685
|
+
this.createItem();
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
break;
|
|
689
|
+
|
|
690
|
+
case 'Backspace':
|
|
691
|
+
if (!this.query && this.items.length && this.isMultiple) {
|
|
692
|
+
e.preventDefault();
|
|
693
|
+
const lastItem = this.items[this.items.length - 1];
|
|
694
|
+
this.removeItem(lastItem);
|
|
695
|
+
}
|
|
696
|
+
break;
|
|
697
|
+
|
|
698
|
+
case 'Delete':
|
|
699
|
+
if (!this.query && this.items.length && this.isMultiple) {
|
|
700
|
+
e.preventDefault();
|
|
701
|
+
const lastItem = this.items[this.items.length - 1];
|
|
702
|
+
this.removeItem(lastItem);
|
|
703
|
+
}
|
|
704
|
+
break;
|
|
705
|
+
|
|
706
|
+
case 'a':
|
|
707
|
+
case 'A':
|
|
708
|
+
if ((e.ctrlKey || e.metaKey) && this.isMultiple) {
|
|
709
|
+
e.preventDefault();
|
|
710
|
+
// Select all - no-op in terms of selection, could be used for copy
|
|
711
|
+
}
|
|
712
|
+
break;
|
|
713
|
+
}
|
|
714
|
+
},
|
|
715
|
+
|
|
716
|
+
// ── Input Handling ──────────────────────────────────────
|
|
717
|
+
onInput() {
|
|
718
|
+
this._trigger('onType', this.query);
|
|
719
|
+
|
|
720
|
+
if (!this.isOpen) {
|
|
721
|
+
this.open();
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Active first option
|
|
725
|
+
this.activeIndex = this._config.setFirstOptionActive || this.query ? 0 : -1;
|
|
726
|
+
|
|
727
|
+
// Remote load
|
|
728
|
+
if (this._config.load && this.query) {
|
|
729
|
+
const q = this.query;
|
|
730
|
+
if (!this.loadedSearches[q]) {
|
|
731
|
+
if (this._debouncedLoad) {
|
|
732
|
+
this._debouncedLoad(q);
|
|
733
|
+
} else {
|
|
734
|
+
this._performLoad(q);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Auto-grow input
|
|
740
|
+
if (this.$refs.searchInput && this.isMultiple) {
|
|
741
|
+
autoGrow(this.$refs.searchInput);
|
|
742
|
+
}
|
|
743
|
+
},
|
|
744
|
+
|
|
745
|
+
onPaste(e) {
|
|
746
|
+
if (!this.isMultiple) return;
|
|
747
|
+
const paste = (e.clipboardData || window.clipboardData).getData('text');
|
|
748
|
+
if (!paste) return;
|
|
749
|
+
|
|
750
|
+
const splitOn = this._config.splitOn || this._config.delimiter;
|
|
751
|
+
if (!splitOn) return;
|
|
752
|
+
|
|
753
|
+
e.preventDefault();
|
|
754
|
+
const regex = splitOn instanceof RegExp ? splitOn : new RegExp('[' + splitOn.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&') + ']');
|
|
755
|
+
const parts = paste.split(regex).map((s) => s.trim()).filter(Boolean);
|
|
756
|
+
|
|
757
|
+
for (const part of parts) {
|
|
758
|
+
// Try to find matching option
|
|
759
|
+
const match = Object.values(this.options).find(
|
|
760
|
+
(o) => o[this._config.labelField]?.toLowerCase() === part.toLowerCase() ||
|
|
761
|
+
o[this._config.valueField]?.toLowerCase() === part.toLowerCase()
|
|
762
|
+
);
|
|
763
|
+
if (match) {
|
|
764
|
+
this.addItem(match[this._config.valueField]);
|
|
765
|
+
} else if (this._config.create) {
|
|
766
|
+
this.createItem(part);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
},
|
|
770
|
+
|
|
771
|
+
// ── Option Selection ────────────────────────────────────
|
|
772
|
+
selectOption(option) {
|
|
773
|
+
if (!option) return;
|
|
774
|
+
if (option[this._config.disabledField]) return;
|
|
775
|
+
|
|
776
|
+
const value = option[this._config.valueField];
|
|
777
|
+
this.addItem(value);
|
|
778
|
+
this.query = '';
|
|
779
|
+
|
|
780
|
+
if (this.$refs.searchInput) {
|
|
781
|
+
this.$refs.searchInput.focus();
|
|
782
|
+
if (this.isMultiple) autoGrow(this.$refs.searchInput);
|
|
783
|
+
}
|
|
784
|
+
},
|
|
785
|
+
|
|
786
|
+
// ── Remote Loading ──────────────────────────────────────
|
|
787
|
+
_performLoad(query) {
|
|
788
|
+
if (!this._config.load) return;
|
|
789
|
+
if (this.loadedSearches[query]) return;
|
|
790
|
+
|
|
791
|
+
this.isLoading = true;
|
|
792
|
+
this.loadedSearches[query] = true;
|
|
793
|
+
|
|
794
|
+
this._config.load(query, (results) => {
|
|
795
|
+
this.isLoading = false;
|
|
796
|
+
if (results && Array.isArray(results)) {
|
|
797
|
+
for (const item of results) {
|
|
798
|
+
this.addOption(item, true);
|
|
799
|
+
}
|
|
800
|
+
this._clearRenderCache();
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
},
|
|
804
|
+
|
|
805
|
+
// ── Rendering Helpers ───────────────────────────────────
|
|
806
|
+
renderOption(option) {
|
|
807
|
+
const config = this._config;
|
|
808
|
+
const label = option[config.labelField] || '';
|
|
809
|
+
|
|
810
|
+
if (config.render?.option) {
|
|
811
|
+
return config.render.option(option, escapeHtml);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
if (config.highlight && this.query) {
|
|
815
|
+
return highlight(label, this.query);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
return escapeHtml(label);
|
|
819
|
+
},
|
|
820
|
+
|
|
821
|
+
renderItem(option) {
|
|
822
|
+
const config = this._config;
|
|
823
|
+
const label = option[config.labelField] || '';
|
|
824
|
+
|
|
825
|
+
if (config.render?.item) {
|
|
826
|
+
return config.render.item(option, escapeHtml);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
return escapeHtml(label);
|
|
830
|
+
},
|
|
831
|
+
|
|
832
|
+
renderOptionCreate() {
|
|
833
|
+
const config = this._config;
|
|
834
|
+
if (config.render?.optionCreate) {
|
|
835
|
+
return config.render.optionCreate({ input: this.query }, escapeHtml);
|
|
836
|
+
}
|
|
837
|
+
return `Add <span class="font-medium">${escapeHtml(this.query)}</span>...`;
|
|
838
|
+
},
|
|
839
|
+
|
|
840
|
+
renderNoResults() {
|
|
841
|
+
const config = this._config;
|
|
842
|
+
if (config.render?.noResults) {
|
|
843
|
+
return config.render.noResults({ query: this.query }, escapeHtml);
|
|
844
|
+
}
|
|
845
|
+
return 'No results found';
|
|
846
|
+
},
|
|
847
|
+
|
|
848
|
+
renderLoading() {
|
|
849
|
+
const config = this._config;
|
|
850
|
+
if (config.render?.loading) {
|
|
851
|
+
return config.render.loading({ query: this.query }, escapeHtml);
|
|
852
|
+
}
|
|
853
|
+
return 'Loading...';
|
|
854
|
+
},
|
|
855
|
+
|
|
856
|
+
// ── Optgroup Support ────────────────────────────────────
|
|
857
|
+
addOptionGroup(id, data) {
|
|
858
|
+
this.optgroups[id] = data;
|
|
859
|
+
this._clearRenderCache();
|
|
860
|
+
},
|
|
861
|
+
|
|
862
|
+
removeOptionGroup(id) {
|
|
863
|
+
delete this.optgroups[id];
|
|
864
|
+
this._clearRenderCache();
|
|
865
|
+
},
|
|
866
|
+
|
|
867
|
+
getGroupedOptions() {
|
|
868
|
+
const options = this.filteredOptions;
|
|
869
|
+
const config = this._config;
|
|
870
|
+
const groups = {};
|
|
871
|
+
const ungrouped = [];
|
|
872
|
+
|
|
873
|
+
for (const opt of options) {
|
|
874
|
+
const groupId = opt[config.optgroupField];
|
|
875
|
+
if (groupId && this.optgroups[groupId]) {
|
|
876
|
+
if (!groups[groupId]) groups[groupId] = [];
|
|
877
|
+
groups[groupId].push(opt);
|
|
878
|
+
} else {
|
|
879
|
+
ungrouped.push(opt);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const result = [];
|
|
884
|
+
// Add grouped options
|
|
885
|
+
for (const [id, items] of Object.entries(groups)) {
|
|
886
|
+
const group = this.optgroups[id];
|
|
887
|
+
result.push({
|
|
888
|
+
id,
|
|
889
|
+
label: group[config.optgroupLabelField] || id,
|
|
890
|
+
options: items,
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
// Add ungrouped
|
|
894
|
+
if (ungrouped.length) {
|
|
895
|
+
result.push({ id: null, label: null, options: ungrouped });
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
return result;
|
|
899
|
+
},
|
|
900
|
+
|
|
901
|
+
get hasOptgroups() {
|
|
902
|
+
return Object.keys(this.optgroups).length > 0;
|
|
903
|
+
},
|
|
904
|
+
|
|
905
|
+
// ── State Control ───────────────────────────────────────
|
|
906
|
+
lock() {
|
|
907
|
+
this.isLocked = true;
|
|
908
|
+
this.close();
|
|
909
|
+
},
|
|
910
|
+
|
|
911
|
+
unlock() {
|
|
912
|
+
this.isLocked = false;
|
|
913
|
+
},
|
|
914
|
+
|
|
915
|
+
disable() {
|
|
916
|
+
this.isDisabled = true;
|
|
917
|
+
this.close();
|
|
918
|
+
},
|
|
919
|
+
|
|
920
|
+
enable() {
|
|
921
|
+
this.isDisabled = false;
|
|
922
|
+
},
|
|
923
|
+
|
|
924
|
+
setMaxItems(max) {
|
|
925
|
+
this._config.maxItems = max;
|
|
926
|
+
if (this.isFull) this.close();
|
|
927
|
+
},
|
|
928
|
+
|
|
929
|
+
// ── Source Element Sync ─────────────────────────────────
|
|
930
|
+
_syncSourceElement() {
|
|
931
|
+
if (!this._sourceEl) return;
|
|
932
|
+
|
|
933
|
+
if (isSelectElement(this._sourceEl)) {
|
|
934
|
+
// Update selected options in the native select
|
|
935
|
+
for (const opt of this._sourceEl.options) {
|
|
936
|
+
opt.selected = this.items.includes(opt.value);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// Add any missing options (user-created)
|
|
940
|
+
for (const val of this.items) {
|
|
941
|
+
const exists = Array.from(this._sourceEl.options).some((o) => o.value === val);
|
|
942
|
+
if (!exists) {
|
|
943
|
+
const optEl = document.createElement('option');
|
|
944
|
+
optEl.value = val;
|
|
945
|
+
optEl.textContent = this.options[val]?.[this._config.labelField] || val;
|
|
946
|
+
optEl.selected = true;
|
|
947
|
+
this._sourceEl.appendChild(optEl);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// Dispatch native change event
|
|
952
|
+
this._sourceEl.dispatchEvent(new Event('change', { bubbles: true }));
|
|
953
|
+
} else {
|
|
954
|
+
// Input element
|
|
955
|
+
this._sourceEl.value = this.isSingle
|
|
956
|
+
? (this.items[0] || '')
|
|
957
|
+
: this.items.join(this._config.delimiter);
|
|
958
|
+
this._sourceEl.dispatchEvent(new Event('input', { bubbles: true }));
|
|
959
|
+
this._sourceEl.dispatchEvent(new Event('change', { bubbles: true }));
|
|
960
|
+
}
|
|
961
|
+
},
|
|
962
|
+
|
|
963
|
+
// ── Scroll Management ───────────────────────────────────
|
|
964
|
+
_scrollToActive() {
|
|
965
|
+
this.$nextTick(() => {
|
|
966
|
+
const dropdown = this.$refs.dropdown;
|
|
967
|
+
if (!dropdown) return;
|
|
968
|
+
const active = dropdown.querySelector('[data-active="true"]');
|
|
969
|
+
if (active) {
|
|
970
|
+
active.scrollIntoView({ block: 'nearest' });
|
|
971
|
+
}
|
|
972
|
+
});
|
|
973
|
+
},
|
|
974
|
+
|
|
975
|
+
// ── Event Triggering ────────────────────────────────────
|
|
976
|
+
_trigger(callbackName, ...args) {
|
|
977
|
+
const cb = this._config[callbackName];
|
|
978
|
+
if (typeof cb === 'function') {
|
|
979
|
+
cb.apply(this, args);
|
|
980
|
+
}
|
|
981
|
+
// Also dispatch a custom DOM event
|
|
982
|
+
const eventName = callbackName.replace(/^on/, '').toLowerCase();
|
|
983
|
+
this.$el.dispatchEvent(
|
|
984
|
+
new CustomEvent(`selectra:${eventName}`, {
|
|
985
|
+
detail: args,
|
|
986
|
+
bubbles: true,
|
|
987
|
+
})
|
|
988
|
+
);
|
|
989
|
+
},
|
|
990
|
+
|
|
991
|
+
// ── Cache ───────────────────────────────────────────────
|
|
992
|
+
_clearRenderCache() {
|
|
993
|
+
this._renderCache = {};
|
|
994
|
+
},
|
|
995
|
+
|
|
996
|
+
// ── Helper: Check if an option is selected ──────────────
|
|
997
|
+
isSelected(option) {
|
|
998
|
+
return this.items.includes(hashKey(option[this._config.valueField]));
|
|
999
|
+
},
|
|
1000
|
+
|
|
1001
|
+
// ── Helper: Option key for x-for ────────────────────────
|
|
1002
|
+
optionKey(option) {
|
|
1003
|
+
return hashKey(option[this._config.valueField]);
|
|
1004
|
+
},
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Register a plugin globally
|
|
1010
|
+
*/
|
|
1011
|
+
export function registerPlugin(name, fn) {
|
|
1012
|
+
pluginRegistry[name] = fn;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* Get the default configuration
|
|
1017
|
+
*/
|
|
1018
|
+
export function getDefaults() {
|
|
1019
|
+
return { ...DEFAULTS };
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
export { DEFAULTS, escapeHtml, hashKey };
|