@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.
@@ -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 };