@doyosi/laraisy 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +1 -1
  2. package/package.json +1 -1
  3. package/src/CodeInput.js +48 -48
  4. package/src/DSAlert.js +352 -352
  5. package/src/DSAvatar.js +207 -207
  6. package/src/DSDelete.js +274 -274
  7. package/src/DSForm.js +568 -568
  8. package/src/DSGridOrTable.js +453 -453
  9. package/src/DSLocaleSwitcher.js +239 -239
  10. package/src/DSLogout.js +293 -293
  11. package/src/DSNotifications.js +365 -365
  12. package/src/DSRestore.js +181 -181
  13. package/src/DSSelect.js +1071 -1071
  14. package/src/DSSelectBox.js +563 -563
  15. package/src/DSSimpleSlider.js +517 -517
  16. package/src/DSSvgFetch.js +69 -69
  17. package/src/DSTable/DSTableExport.js +68 -68
  18. package/src/DSTable/DSTableFilter.js +224 -224
  19. package/src/DSTable/DSTablePagination.js +136 -136
  20. package/src/DSTable/DSTableSearch.js +40 -40
  21. package/src/DSTable/DSTableSelection.js +192 -192
  22. package/src/DSTable/DSTableSort.js +58 -58
  23. package/src/DSTable.js +353 -353
  24. package/src/DSTabs.js +488 -488
  25. package/src/DSUpload.js +887 -887
  26. package/dist/CodeInput.d.ts +0 -10
  27. package/dist/DSAlert.d.ts +0 -112
  28. package/dist/DSAvatar.d.ts +0 -45
  29. package/dist/DSDelete.d.ts +0 -61
  30. package/dist/DSForm.d.ts +0 -151
  31. package/dist/DSGridOrTable/DSGOTRenderer.d.ts +0 -60
  32. package/dist/DSGridOrTable/DSGOTViewToggle.d.ts +0 -26
  33. package/dist/DSGridOrTable.d.ts +0 -296
  34. package/dist/DSLocaleSwitcher.d.ts +0 -71
  35. package/dist/DSLogout.d.ts +0 -76
  36. package/dist/DSNotifications.d.ts +0 -54
  37. package/dist/DSRestore.d.ts +0 -56
  38. package/dist/DSSelect.d.ts +0 -221
  39. package/dist/DSSelectBox.d.ts +0 -123
  40. package/dist/DSSimpleSlider.d.ts +0 -136
  41. package/dist/DSSvgFetch.d.ts +0 -17
  42. package/dist/DSTable/DSTableExport.d.ts +0 -11
  43. package/dist/DSTable/DSTableFilter.d.ts +0 -40
  44. package/dist/DSTable/DSTablePagination.d.ts +0 -12
  45. package/dist/DSTable/DSTableSearch.d.ts +0 -8
  46. package/dist/DSTable/DSTableSelection.d.ts +0 -46
  47. package/dist/DSTable/DSTableSort.d.ts +0 -8
  48. package/dist/DSTable.d.ts +0 -116
  49. package/dist/DSTabs.d.ts +0 -156
  50. package/dist/DSUpload.d.ts +0 -220
  51. package/dist/index.d.ts +0 -17
package/src/DSSelect.js CHANGED
@@ -1,1071 +1,1071 @@
1
- /**
2
- * DSSelect
3
- *
4
- * A comprehensive searchable select component with:
5
- * - Static options, JSON data, and Axios remote fetching
6
- * - Single and multiple selection modes
7
- * - Laravel old/current/default value support
8
- * - Full event system
9
- * - Multiple instances support
10
- */
11
- export class DSSelect {
12
- static instances = new Map();
13
- static instanceCounter = 0;
14
-
15
- /**
16
- * Default Icons
17
- */
18
- static icons = {
19
- search: `<svg class="w-4 h-4 text-base-content/50" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>`,
20
- chevron: `<svg class="w-4 h-4 text-base-content/50 transition-transform duration-200" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>`,
21
- close: `<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>`,
22
- loading: `<span class="loading loading-spinner loading-sm"></span>`,
23
- check: `<svg class="w-4 h-4 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>`,
24
- clear: `<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>`
25
- };
26
-
27
- /**
28
- * Default configuration
29
- */
30
- static defaults = {
31
- // Data sources
32
- options: [], // [{id: 1, name: 'Option'}] or {1: 'Option'}
33
- axiosUrl: null, // Remote data URL
34
- axiosMethod: 'GET', // HTTP method for remote
35
- axiosParams: {}, // Additional params
36
- axiosSearchParam: 'search', // Search query param name
37
- axiosDataPath: 'data', // Path to options in response
38
-
39
- // Value configuration
40
- valueKey: 'id', // Key for option value
41
- labelKey: 'name', // Key for option label
42
-
43
- // Selection
44
- multiple: false, // Allow multiple selection
45
- maxSelections: null, // Max items in multiple mode
46
-
47
- // Search
48
- searchable: true, // Enable search input
49
- searchMinLength: 0, // Min chars before search
50
- searchDebounce: 300, // Debounce delay (ms)
51
-
52
- // UI
53
- placeholder: 'Select...',
54
- searchPlaceholder: 'Type to search...',
55
- noResultsText: 'No results found',
56
- loadingText: 'Loading...',
57
- clearable: true, // Show clear button
58
- disabled: false,
59
-
60
- // Dropdown
61
- closeOnSelect: true, // Close after selection (single mode)
62
- openOnFocus: true, // Open when input focused
63
- maxHeight: '240px', // Dropdown max height
64
-
65
- // Classes
66
- wrapperClass: '',
67
- inputClass: '',
68
- dropdownClass: '',
69
- optionClass: '',
70
-
71
- // Translations (for i18n)
72
- translations: {
73
- noResults: 'No results found',
74
- loading: 'Loading...',
75
- maxSelected: 'Maximum {max} items allowed'
76
- }
77
- };
78
-
79
- /**
80
- * @param {string|HTMLElement} selector - Container element or selector
81
- * @param {Object} config - Configuration options
82
- */
83
- constructor(selector, config = {}) {
84
- this.instanceId = `ds-select-${++DSSelect.instanceCounter}`;
85
- this.wrapper = typeof selector === 'string'
86
- ? document.querySelector(selector)
87
- : selector;
88
-
89
- if (!this.wrapper) {
90
- throw new Error('DSSelect: Container element not found.');
91
- }
92
-
93
- // Merge config with data attributes and defaults
94
- this.cfg = this._buildConfig(config);
95
-
96
- // State
97
- this._options = [];
98
- this._filteredOptions = [];
99
- this._selected = this.cfg.multiple ? [] : null;
100
- this._isOpen = false;
101
- this._isLoading = false;
102
- this._searchTerm = '';
103
- this._highlightedIndex = -1;
104
-
105
- // Debounce timer
106
- this._searchTimer = null;
107
-
108
- // Event listeners storage for cleanup
109
- this._listeners = {};
110
- this._boundHandlers = {};
111
-
112
- // Initialize
113
- this._init();
114
-
115
- // Register instance
116
- DSSelect.instances.set(this.instanceId, this);
117
- this.wrapper.dataset.dsSelectId = this.instanceId;
118
- }
119
-
120
- /**
121
- * Static factory method
122
- */
123
- static create(selector, config = {}) {
124
- return new DSSelect(selector, config);
125
- }
126
-
127
- /**
128
- * Get instance by element
129
- */
130
- static getInstance(element) {
131
- const el = typeof element === 'string' ? document.querySelector(element) : element;
132
- if (!el) return null;
133
- const id = el.dataset.dsSelectId;
134
- return id ? DSSelect.instances.get(id) : null;
135
- }
136
-
137
- /**
138
- * Auto-initialize all elements with [data-ds-select]
139
- */
140
- static initAll(selector = '[data-ds-select]') {
141
- document.querySelectorAll(selector).forEach(el => {
142
- if (!el.dataset.dsSelectId) {
143
- new DSSelect(el);
144
- }
145
- });
146
- }
147
-
148
- // ==================== INITIALIZATION ====================
149
-
150
- _buildConfig(userConfig) {
151
- const dataConfig = this._parseDataAttributes();
152
- return { ...DSSelect.defaults, ...dataConfig, ...userConfig };
153
- }
154
-
155
- _parseDataAttributes() {
156
- const data = this.wrapper.dataset;
157
- const config = {};
158
-
159
- // Parse known data attributes
160
- if (data.options) {
161
- try { config.options = JSON.parse(data.options); } catch { }
162
- }
163
- if (data.axiosUrl) config.axiosUrl = data.axiosUrl;
164
- if (data.axiosMethod) config.axiosMethod = data.axiosMethod;
165
- if (data.multiple !== undefined) config.multiple = data.multiple === 'true';
166
- if (data.placeholder) config.placeholder = data.placeholder;
167
- if (data.searchPlaceholder) config.searchPlaceholder = data.searchPlaceholder;
168
- if (data.valueKey) config.valueKey = data.valueKey;
169
- if (data.labelKey) config.labelKey = data.labelKey;
170
- if (data.clearable !== undefined) config.clearable = data.clearable === 'true';
171
- if (data.disabled !== undefined) config.disabled = data.disabled === 'true';
172
- if (data.maxSelections) config.maxSelections = parseInt(data.maxSelections, 10);
173
- if (data.value) {
174
- try {
175
- config.initialValue = JSON.parse(data.value);
176
- } catch {
177
- config.initialValue = data.value;
178
- }
179
- }
180
-
181
- return config;
182
- }
183
-
184
- _init() {
185
- this._buildDOM();
186
- this._cacheElements();
187
- this._loadInitialOptions();
188
- this._setInitialValue();
189
- this._bindEvents();
190
-
191
- // Ensure UI state is correct (especially for empty multiple selects)
192
- this._updateUI();
193
-
194
- if (this.cfg.disabled) {
195
- this.disable();
196
- }
197
- }
198
-
199
- _buildDOM() {
200
- // Get existing hidden input if any (for ID)
201
- const existingInput = this.wrapper.querySelector('input[type="hidden"]');
202
-
203
- // Always use data-name as the base name, strip any existing [] to prevent double-brackets on hot reload
204
- let baseName = this.wrapper.dataset.name || 'select';
205
- baseName = baseName.replace(/\[\]$/, ''); // Remove trailing [] if present
206
-
207
- const inputName = baseName + (this.cfg.multiple ? '[]' : '');
208
- const inputId = existingInput?.id || this.wrapper.dataset.id || this.instanceId;
209
-
210
- this.wrapper.classList.add('ds-select-wrapper', 'relative', 'w-full');
211
- if (this.cfg.wrapperClass) {
212
- this.wrapper.classList.add(...this.cfg.wrapperClass.split(' '));
213
- }
214
-
215
- this.wrapper.innerHTML = `
216
- <!-- Hidden Input(s) for form submission -->
217
- <input type="hidden"
218
- name="${inputName}"
219
- id="${inputId}"
220
- data-ds-select-value>
221
-
222
- <!-- Main Control -->
223
- <div class="ds-select-control focus-within:outline-none input input-bordered w-full flex items-center gap-1 min-h-10 cursor-pointer py-2 h-auto pr-2 ${this.cfg.inputClass}"
224
- data-ds-select-control>
225
-
226
- <!-- Content Area (Tags + Search) -->
227
- <div class="ds-select-content flex flex-wrap items-center gap-1 flex-1 min-w-0">
228
- <!-- Selected Tags (Multiple Mode) -->
229
- <div class="ds-select-tags contents" data-ds-select-tags></div>
230
-
231
- <!-- Search Input -->
232
- <input type="text"
233
- class="ds-select-search flex-1 min-w-[60px] bg-transparent border-0 outline-none text-sm p-0"
234
- placeholder="${this.cfg.placeholder}"
235
- data-ds-select-search
236
- autocomplete="off"
237
- ${this.cfg.disabled ? 'disabled' : ''}>
238
- </div>
239
-
240
- <!-- Icons Container (Always stays right) -->
241
- <div class="ds-select-icons flex items-center gap-1 ml-auto flex-shrink-0">
242
- <button type="button"
243
- class="ds-select-clear btn btn-ghost btn-xs btn-circle hidden"
244
- data-ds-select-clear
245
- tabindex="-1">
246
- ${DSSelect.icons.clear}
247
- </button>
248
- <span class="ds-select-loading hidden" data-ds-select-loading>
249
- ${DSSelect.icons.loading}
250
- </span>
251
- <span class="ds-select-chevron" data-ds-select-chevron>
252
- ${DSSelect.icons.chevron}
253
- </span>
254
- </div>
255
- </div>
256
-
257
- <!-- Dropdown -->
258
- <div class="ds-select-dropdown absolute left-0 right-0 top-full mt-1 z-50 hidden"
259
- data-ds-select-dropdown>
260
- <ul class="ds-select-options bg-base-100 rounded-box shadow-lg border border-base-300 p-2 overflow-y-auto text-sm w-full flex flex-col gap-1 ${this.cfg.dropdownClass}"
261
- style="max-height: ${this.cfg.maxHeight}"
262
- data-ds-select-options
263
- tabindex="-1">
264
- </ul>
265
- </div>
266
- `;
267
- }
268
-
269
- _cacheElements() {
270
- this.elements = {
271
- hiddenInput: this.wrapper.querySelector('[data-ds-select-value]'),
272
- control: this.wrapper.querySelector('[data-ds-select-control]'),
273
- tags: this.wrapper.querySelector('[data-ds-select-tags]'),
274
- search: this.wrapper.querySelector('[data-ds-select-search]'),
275
- clear: this.wrapper.querySelector('[data-ds-select-clear]'),
276
- loading: this.wrapper.querySelector('[data-ds-select-loading]'),
277
- chevron: this.wrapper.querySelector('[data-ds-select-chevron]'),
278
- dropdown: this.wrapper.querySelector('[data-ds-select-dropdown]'),
279
- options: this.wrapper.querySelector('[data-ds-select-options]')
280
- };
281
- }
282
-
283
- _loadInitialOptions() {
284
- // Priority: config options > data-options > axios
285
- if (this.cfg.options && (Array.isArray(this.cfg.options) ? this.cfg.options.length : Object.keys(this.cfg.options).length)) {
286
- this._options = this._normalizeOptions(this.cfg.options);
287
- this._filteredOptions = [...this._options];
288
- } else if (this.cfg.axiosUrl) {
289
- this._fetchRemoteOptions();
290
- }
291
- }
292
-
293
- _normalizeOptions(options) {
294
- // Handle array of objects
295
- if (Array.isArray(options)) {
296
- return options.map(opt => {
297
- if (typeof opt === 'object') {
298
- return {
299
- value: String(opt[this.cfg.valueKey] ?? opt.id ?? opt.value),
300
- label: opt[this.cfg.labelKey] ?? opt.name ?? opt.label ?? opt.text,
301
- data: opt
302
- };
303
- }
304
- return { value: String(opt), label: String(opt), data: opt };
305
- });
306
- }
307
-
308
- // Handle object (key-value pairs)
309
- if (typeof options === 'object') {
310
- return Object.entries(options).map(([key, value]) => ({
311
- value: String(key),
312
- label: String(value),
313
- data: { [this.cfg.valueKey]: key, [this.cfg.labelKey]: value }
314
- }));
315
- }
316
-
317
- return [];
318
- }
319
-
320
- _setInitialValue() {
321
- // Priority: old (Laravel) > current > default > config
322
- const oldValue = this.wrapper.dataset.oldValue;
323
- const currentValue = this.wrapper.dataset.currentValue;
324
- const defaultValue = this.wrapper.dataset.defaultValue;
325
- const configValue = this.cfg.initialValue;
326
-
327
- let value = oldValue ?? currentValue ?? defaultValue ?? configValue;
328
-
329
- if (value !== undefined && value !== null && value !== '') {
330
- if (this.cfg.multiple) {
331
- let values;
332
- if (Array.isArray(value)) {
333
- values = value;
334
- } else if (typeof value === 'string') {
335
- // Try to parse as JSON first (for encoded arrays like ["1","2"])
336
- try {
337
- const parsed = JSON.parse(value);
338
- values = Array.isArray(parsed) ? parsed : [parsed];
339
- } catch {
340
- // Fallback to comma-separated string
341
- values = value.split(',');
342
- }
343
- } else {
344
- values = [value];
345
- }
346
- values.forEach(v => this._selectValue(String(v).trim(), false));
347
- } else {
348
- this._selectValue(String(value), false);
349
- }
350
- }
351
- }
352
-
353
- // ==================== EVENTS ====================
354
-
355
- _bindEvents() {
356
- // Store bound handlers for cleanup
357
- this._boundHandlers = {
358
- onControlClick: this._onControlClick.bind(this),
359
- onSearchInput: this._onSearchInput.bind(this),
360
- onSearchKeydown: this._onSearchKeydown.bind(this),
361
- onSearchFocus: this._onSearchFocus.bind(this),
362
- onClearClick: this._onClearClick.bind(this),
363
- onDocumentClick: this._onDocumentClick.bind(this),
364
- onOptionClick: this._onOptionClick.bind(this)
365
- };
366
-
367
- // Control click
368
- this.elements.control.addEventListener('click', this._boundHandlers.onControlClick);
369
-
370
- // Search input
371
- this.elements.search.addEventListener('input', this._boundHandlers.onSearchInput);
372
- this.elements.search.addEventListener('keydown', this._boundHandlers.onSearchKeydown);
373
- this.elements.search.addEventListener('focus', this._boundHandlers.onSearchFocus);
374
-
375
- // Clear button
376
- this.elements.clear.addEventListener('click', this._boundHandlers.onClearClick);
377
-
378
- // Outside click
379
- document.addEventListener('click', this._boundHandlers.onDocumentClick);
380
-
381
- // Options list delegation
382
- this.elements.options.addEventListener('click', this._boundHandlers.onOptionClick);
383
- }
384
-
385
- _onControlClick(e) {
386
- if (this.cfg.disabled) return;
387
- if (e.target.closest('[data-ds-select-clear]')) return;
388
-
389
- this.elements.search.focus();
390
- if (!this._isOpen) {
391
- this.open();
392
- }
393
- }
394
-
395
- _onSearchInput(e) {
396
- const term = e.target.value;
397
- this._searchTerm = term;
398
-
399
- this._emit('search', { term });
400
-
401
- // Debounce search
402
- clearTimeout(this._searchTimer);
403
- this._searchTimer = setTimeout(() => {
404
- this._performSearch(term);
405
- }, this.cfg.searchDebounce);
406
- }
407
-
408
- _onSearchKeydown(e) {
409
- switch (e.key) {
410
- case 'ArrowDown':
411
- e.preventDefault();
412
- if (!this._isOpen) {
413
- this.open();
414
- } else {
415
- this._highlightNext();
416
- }
417
- break;
418
-
419
- case 'ArrowUp':
420
- e.preventDefault();
421
- this._highlightPrev();
422
- break;
423
-
424
- case 'Enter':
425
- e.preventDefault();
426
- if (this._highlightedIndex >= 0 && this._filteredOptions[this._highlightedIndex]) {
427
- this._selectOption(this._filteredOptions[this._highlightedIndex]);
428
- }
429
- break;
430
-
431
- case 'Escape':
432
- e.preventDefault();
433
- this.close();
434
- break;
435
-
436
- case 'Backspace':
437
- if (this.cfg.multiple && !this._searchTerm && this._selected.length > 0) {
438
- this._deselectValue(this._selected[this._selected.length - 1].value);
439
- }
440
- break;
441
- }
442
- }
443
-
444
- _onSearchFocus() {
445
- if (this.cfg.openOnFocus && !this._isOpen) {
446
- this.open();
447
- }
448
- }
449
-
450
- _onClearClick(e) {
451
- e.preventDefault();
452
- e.stopPropagation();
453
- this.clear();
454
- }
455
-
456
- _onDocumentClick(e) {
457
- if (!this.wrapper.contains(e.target) && this._isOpen) {
458
- this.close();
459
- }
460
- }
461
-
462
- _onOptionClick(e) {
463
- const optionEl = e.target.closest('[data-ds-option-value]');
464
- if (!optionEl) return;
465
-
466
- const value = optionEl.dataset.dsOptionValue;
467
- const option = this._options.find(o => o.value === value);
468
-
469
- if (option) {
470
- this._selectOption(option);
471
- }
472
- }
473
-
474
- // ==================== SEARCH ====================
475
-
476
- _performSearch(term) {
477
- if (this.cfg.axiosUrl && term.length >= this.cfg.searchMinLength) {
478
- this._fetchRemoteOptions(term);
479
- } else {
480
- this._filterOptions(term);
481
- }
482
- }
483
-
484
- _filterOptions(term) {
485
- const searchTerm = term.toLowerCase().trim();
486
-
487
- if (!searchTerm) {
488
- this._filteredOptions = [...this._options];
489
- } else {
490
- this._filteredOptions = this._options.filter(opt =>
491
- opt.label.toLowerCase().includes(searchTerm)
492
- );
493
- }
494
-
495
- this._highlightedIndex = this._filteredOptions.length > 0 ? 0 : -1;
496
- this._renderOptions();
497
- }
498
-
499
- async _fetchRemoteOptions(searchTerm = '') {
500
- if (!this.cfg.axiosUrl) return;
501
-
502
- this._setLoading(true);
503
-
504
- const params = {
505
- ...this.cfg.axiosParams,
506
- [this.cfg.axiosSearchParam]: searchTerm
507
- };
508
-
509
- try {
510
- let response;
511
-
512
- // Use axios if available, otherwise fetch
513
- if (window.axios) {
514
- response = await window.axios({
515
- method: this.cfg.axiosMethod,
516
- url: this.cfg.axiosUrl,
517
- params: this.cfg.axiosMethod.toUpperCase() === 'GET' ? params : undefined,
518
- data: this.cfg.axiosMethod.toUpperCase() !== 'GET' ? params : undefined
519
- });
520
- response = response.data;
521
- } else {
522
- const url = new URL(this.cfg.axiosUrl, window.location.origin);
523
- if (this.cfg.axiosMethod.toUpperCase() === 'GET') {
524
- Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
525
- }
526
-
527
- const fetchOptions = {
528
- method: this.cfg.axiosMethod,
529
- headers: {
530
- 'Accept': 'application/json',
531
- 'Content-Type': 'application/json',
532
- 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || ''
533
- }
534
- };
535
-
536
- if (this.cfg.axiosMethod.toUpperCase() !== 'GET') {
537
- fetchOptions.body = JSON.stringify(params);
538
- }
539
-
540
- const res = await fetch(url.toString(), fetchOptions);
541
- response = await res.json();
542
- }
543
-
544
- // Extract data from response
545
- let data = response;
546
- if (this.cfg.axiosDataPath) {
547
- const paths = this.cfg.axiosDataPath.split('.');
548
- for (const path of paths) {
549
- data = data?.[path];
550
- }
551
- }
552
-
553
- this._options = this._normalizeOptions(data || []);
554
- this._filteredOptions = [...this._options];
555
-
556
- // IMPORTANT: Set loading to false BEFORE rendering so options actually display
557
- this._setLoading(false);
558
- this._renderOptions();
559
-
560
- this._emit('load', { options: this._options });
561
-
562
- } catch (error) {
563
- console.error('DSSelect: Failed to fetch options', error);
564
- this._setLoading(false);
565
- this._emit('error', { error });
566
- }
567
-
568
- }
569
-
570
- // ==================== SELECTION ====================
571
-
572
- _selectOption(option) {
573
- if (this.cfg.multiple) {
574
- const isSelected = this._selected.some(s => s.value === option.value);
575
-
576
- if (isSelected) {
577
- this._deselectValue(option.value);
578
- } else {
579
- // Check max selections
580
- if (this.cfg.maxSelections && this._selected.length >= this.cfg.maxSelections) {
581
- this._emit('maxReached', { max: this.cfg.maxSelections });
582
- return;
583
- }
584
- this._selected.push(option);
585
- this._emit('select', { option, selected: this._selected });
586
- }
587
-
588
- // Clear search and reset filter in multiple mode
589
- this.elements.search.value = '';
590
- this._searchTerm = '';
591
- this._filteredOptions = [...this._options];
592
- } else {
593
- const prevSelected = this._selected;
594
- this._selected = option;
595
- this._emit('select', { option, previous: prevSelected });
596
-
597
- if (this.cfg.closeOnSelect) {
598
- this.close();
599
- }
600
- }
601
-
602
- this._updateUI();
603
- this._emit('change', { value: this.getValue(), selected: this._selected });
604
- }
605
-
606
- _selectValue(value, triggerChange = true) {
607
- const option = this._options.find(o => o.value === value);
608
- if (!option) return false;
609
-
610
- if (this.cfg.multiple) {
611
- if (!this._selected.some(s => s.value === value)) {
612
- this._selected.push(option);
613
- }
614
- } else {
615
- this._selected = option;
616
- }
617
-
618
- this._updateUI();
619
-
620
- if (triggerChange) {
621
- this._emit('change', { value: this.getValue(), selected: this._selected });
622
- }
623
-
624
- return true;
625
- }
626
-
627
- _deselectValue(value) {
628
- if (!this.cfg.multiple) {
629
- this._selected = null;
630
- } else {
631
- const option = this._selected.find(s => s.value === value);
632
- this._selected = this._selected.filter(s => s.value !== value);
633
- if (option) {
634
- this._emit('deselect', { option, selected: this._selected });
635
- }
636
- }
637
-
638
- this._updateUI();
639
- this._emit('change', { value: this.getValue(), selected: this._selected });
640
- }
641
-
642
- // ==================== UI RENDERING ====================
643
-
644
- _updateUI() {
645
- this._updateHiddenInput();
646
- this._updateTags();
647
- this._updateSearchPlaceholder();
648
- this._updateClearButton();
649
- this._renderOptions();
650
- }
651
-
652
- _updateHiddenInput() {
653
- if (this.cfg.multiple) {
654
- // For multiple, we need multiple hidden inputs
655
- const name = this.elements.hiddenInput.name;
656
-
657
- // Remove existing additional inputs
658
- this.wrapper.querySelectorAll('input[data-ds-select-multi-value]').forEach(el => el.remove());
659
-
660
- if (this._selected.length === 0) {
661
- // Disable the hidden input so it won't be submitted (prevents [""] being sent)
662
- this.elements.hiddenInput.value = '';
663
- this.elements.hiddenInput.disabled = true;
664
- } else {
665
- // Enable and create hidden input for each selected value
666
- this.elements.hiddenInput.disabled = false;
667
- this.elements.hiddenInput.value = this._selected[0].value;
668
-
669
- this._selected.slice(1).forEach(opt => {
670
- const input = document.createElement('input');
671
- input.type = 'hidden';
672
- input.name = name;
673
- input.value = opt.value;
674
- input.dataset.dsSelectMultiValue = 'true';
675
- this.wrapper.insertBefore(input, this.elements.control);
676
- });
677
- }
678
- } else {
679
- this.elements.hiddenInput.value = this._selected?.value || '';
680
- }
681
- }
682
-
683
- _updateTags() {
684
- if (!this.cfg.multiple) {
685
- // For single select, show selected value in search input
686
- this.elements.tags.innerHTML = '';
687
- if (this._selected && !this._isOpen) {
688
- this.elements.search.value = this._selected.label;
689
- }
690
- return;
691
- }
692
-
693
- // Multiple mode - render tags
694
- this.elements.tags.innerHTML = this._selected.map(opt => `
695
- <span class="ds-select-tag badge badge-primary gap-1 py-3">
696
- <span class="text-xs">${this._escapeHtml(opt.label)}</span>
697
- <button type="button"
698
- class="btn btn-ghost btn-xs btn-circle -mr-1"
699
- data-ds-tag-remove="${opt.value}"
700
- tabindex="-1">
701
- ${DSSelect.icons.close}
702
- </button>
703
- </span>
704
- `).join('');
705
-
706
- // Bind remove handlers
707
- this.elements.tags.querySelectorAll('[data-ds-tag-remove]').forEach(btn => {
708
- btn.addEventListener('click', (e) => {
709
- e.stopPropagation();
710
- this._deselectValue(btn.dataset.dsTagRemove);
711
- this.elements.search.focus();
712
- });
713
- });
714
- }
715
-
716
- _updateSearchPlaceholder() {
717
- if (this.cfg.multiple) {
718
- this.elements.search.placeholder = this._selected.length > 0
719
- ? ''
720
- : this.cfg.placeholder;
721
- } else {
722
- this.elements.search.placeholder = this._selected
723
- ? ''
724
- : this.cfg.placeholder;
725
- }
726
- }
727
-
728
- _updateClearButton() {
729
- const hasValue = this.cfg.multiple
730
- ? this._selected.length > 0
731
- : this._selected !== null;
732
-
733
- if (this.cfg.clearable && hasValue) {
734
- this.elements.clear.classList.remove('hidden');
735
- } else {
736
- this.elements.clear.classList.add('hidden');
737
- }
738
- }
739
-
740
- _renderOptions() {
741
- if (this._isLoading) {
742
- this.elements.options.innerHTML = `
743
- <li class="disabled text-center py-4 text-base-content/50">
744
- ${DSSelect.icons.loading}
745
- <span class="ml-2">${this.cfg.loadingText}</span>
746
- </li>
747
- `;
748
- return;
749
- }
750
-
751
- if (this._filteredOptions.length === 0) {
752
- this.elements.options.innerHTML = `
753
- <li class="disabled text-center py-4 text-base-content/50">
754
- ${this.cfg.noResultsText}
755
- </li>
756
- `;
757
- return;
758
- }
759
-
760
- this.elements.options.innerHTML = this._filteredOptions.map((opt, idx) => {
761
- const isSelected = this.cfg.multiple
762
- ? this._selected.some(s => s.value === opt.value)
763
- : this._selected?.value === opt.value;
764
- const isHighlighted = idx === this._highlightedIndex;
765
-
766
- return `
767
- <li class="w-full">
768
- <a class="ds-select-option flex items-center justify-between gap-2 w-full px-3 py-2 rounded-lg cursor-pointer hover:bg-base-200 ${isSelected ? 'bg-primary/10 text-primary' : ''} ${isHighlighted ? 'bg-base-200' : ''} ${this.cfg.optionClass}"
769
- data-ds-option-value="${this._escapeHtml(opt.value)}"
770
- data-ds-option-index="${idx}">
771
- <span>${this._escapeHtml(opt.label)}</span>
772
- ${isSelected ? DSSelect.icons.check : ''}
773
- </a>
774
- </li>
775
- `;
776
- }).join('');
777
- }
778
-
779
- _highlightNext() {
780
- if (this._filteredOptions.length === 0) return;
781
-
782
- this._highlightedIndex = Math.min(
783
- this._highlightedIndex + 1,
784
- this._filteredOptions.length - 1
785
- );
786
- this._updateHighlight();
787
- }
788
-
789
- _highlightPrev() {
790
- if (this._filteredOptions.length === 0) return;
791
-
792
- this._highlightedIndex = Math.max(this._highlightedIndex - 1, 0);
793
- this._updateHighlight();
794
- }
795
-
796
- _updateHighlight() {
797
- this.elements.options.querySelectorAll('.ds-select-option').forEach((el, idx) => {
798
- el.classList.toggle('focus', idx === this._highlightedIndex);
799
- });
800
-
801
- // Scroll into view
802
- const highlighted = this.elements.options.querySelector('.focus');
803
- if (highlighted) {
804
- highlighted.scrollIntoView({ block: 'nearest' });
805
- }
806
- }
807
-
808
- _setLoading(loading) {
809
- this._isLoading = loading;
810
- this.elements.loading.classList.toggle('hidden', !loading);
811
- this.elements.chevron.classList.toggle('hidden', loading);
812
- }
813
-
814
- // ==================== PUBLIC API ====================
815
-
816
- /**
817
- * Open the dropdown
818
- */
819
- open() {
820
- if (this._isOpen || this.cfg.disabled) return;
821
-
822
- this._isOpen = true;
823
- this.elements.dropdown.classList.remove('hidden');
824
- this.elements.chevron.querySelector('svg')?.classList.add('rotate-180');
825
-
826
- // Reset search for single mode
827
- if (!this.cfg.multiple && this._selected) {
828
- this.elements.search.value = '';
829
- }
830
-
831
- this._filteredOptions = [...this._options];
832
- this._highlightedIndex = 0;
833
- this._renderOptions();
834
-
835
- this._emit('open');
836
- }
837
-
838
- /**
839
- * Close the dropdown
840
- */
841
- close() {
842
- if (!this._isOpen) return;
843
-
844
- this._isOpen = false;
845
- this.elements.dropdown.classList.add('hidden');
846
- this.elements.chevron.querySelector('svg')?.classList.remove('rotate-180');
847
-
848
- // Restore selection display for single mode
849
- if (!this.cfg.multiple) {
850
- this.elements.search.value = this._selected?.label || '';
851
- }
852
-
853
- this._searchTerm = '';
854
-
855
- this._emit('close');
856
- }
857
-
858
- /**
859
- * Toggle dropdown
860
- */
861
- toggle() {
862
- this._isOpen ? this.close() : this.open();
863
- }
864
-
865
- /**
866
- * Get current value(s)
867
- */
868
- getValue() {
869
- if (this.cfg.multiple) {
870
- return this._selected.map(s => s.value);
871
- }
872
- return this._selected?.value || null;
873
- }
874
-
875
- /**
876
- * Get selected option(s) with full data
877
- */
878
- getSelected() {
879
- return this._selected;
880
- }
881
-
882
- /**
883
- * Set value(s)
884
- */
885
- setValue(value) {
886
- if (this.cfg.multiple) {
887
- this._selected = [];
888
- const values = Array.isArray(value) ? value : [value];
889
- values.forEach(v => this._selectValue(String(v), false));
890
- } else {
891
- this._selected = null;
892
- if (value !== null && value !== undefined) {
893
- this._selectValue(String(value), false);
894
- }
895
- }
896
- this._updateUI();
897
- this._emit('change', { value: this.getValue(), selected: this._selected });
898
- }
899
-
900
- /**
901
- * Clear all selections
902
- */
903
- clear() {
904
- if (this.cfg.multiple) {
905
- this._selected = [];
906
- } else {
907
- this._selected = null;
908
- }
909
-
910
- this.elements.search.value = '';
911
- this._searchTerm = '';
912
- this._updateUI();
913
-
914
- this._emit('clear');
915
- this._emit('change', { value: this.getValue(), selected: this._selected });
916
- }
917
-
918
- /**
919
- * Reset to initial value
920
- */
921
- reset() {
922
- this.clear();
923
- this._setInitialValue();
924
- this._emit('reset');
925
- }
926
-
927
- /**
928
- * Add option(s)
929
- */
930
- addOption(option) {
931
- const normalized = this._normalizeOptions(Array.isArray(option) ? option : [option]);
932
- this._options.push(...normalized);
933
- this._filteredOptions = [...this._options];
934
- this._renderOptions();
935
- }
936
-
937
- /**
938
- * Remove option by value
939
- */
940
- removeOption(value) {
941
- this._options = this._options.filter(o => o.value !== String(value));
942
- this._filteredOptions = this._filteredOptions.filter(o => o.value !== String(value));
943
-
944
- // Also deselect if selected
945
- if (this.cfg.multiple) {
946
- this._selected = this._selected.filter(s => s.value !== String(value));
947
- } else if (this._selected?.value === String(value)) {
948
- this._selected = null;
949
- }
950
-
951
- this._updateUI();
952
- }
953
-
954
- /**
955
- * Set options (replace all)
956
- */
957
- setOptions(options) {
958
- this._options = this._normalizeOptions(options);
959
- this._filteredOptions = [...this._options];
960
- this._renderOptions();
961
- }
962
-
963
- /**
964
- * Refresh/reload options from remote
965
- */
966
- refresh() {
967
- if (this.cfg.axiosUrl) {
968
- this._fetchRemoteOptions(this._searchTerm);
969
- }
970
- }
971
-
972
- /**
973
- * Enable the select
974
- */
975
- enable() {
976
- this.cfg.disabled = false;
977
- this.elements.search.disabled = false;
978
- this.elements.control.classList.remove('opacity-50', 'cursor-not-allowed');
979
- this._emit('enable');
980
- }
981
-
982
- /**
983
- * Disable the select
984
- */
985
- disable() {
986
- this.cfg.disabled = true;
987
- this.elements.search.disabled = true;
988
- this.elements.control.classList.add('opacity-50', 'cursor-not-allowed');
989
- this.close();
990
- this._emit('disable');
991
- }
992
-
993
- /**
994
- * Subscribe to events
995
- */
996
- on(event, handler) {
997
- if (!this._listeners[event]) {
998
- this._listeners[event] = new Set();
999
- }
1000
- this._listeners[event].add(handler);
1001
- return this;
1002
- }
1003
-
1004
- /**
1005
- * Unsubscribe from events
1006
- */
1007
- off(event, handler) {
1008
- if (this._listeners[event]) {
1009
- if (handler) {
1010
- this._listeners[event].delete(handler);
1011
- } else {
1012
- this._listeners[event].clear();
1013
- }
1014
- }
1015
- return this;
1016
- }
1017
-
1018
- /**
1019
- * Destroy instance
1020
- */
1021
- destroy() {
1022
- // Remove event listeners
1023
- this.elements.control.removeEventListener('click', this._boundHandlers.onControlClick);
1024
- this.elements.search.removeEventListener('input', this._boundHandlers.onSearchInput);
1025
- this.elements.search.removeEventListener('keydown', this._boundHandlers.onSearchKeydown);
1026
- this.elements.search.removeEventListener('focus', this._boundHandlers.onSearchFocus);
1027
- this.elements.clear.removeEventListener('click', this._boundHandlers.onClearClick);
1028
- document.removeEventListener('click', this._boundHandlers.onDocumentClick);
1029
- this.elements.options.removeEventListener('click', this._boundHandlers.onOptionClick);
1030
-
1031
- // Clear state
1032
- this._listeners = {};
1033
- clearTimeout(this._searchTimer);
1034
-
1035
- // Remove from instances
1036
- DSSelect.instances.delete(this.instanceId);
1037
- delete this.wrapper.dataset.dsSelectId;
1038
-
1039
- this._emit('destroy');
1040
- }
1041
-
1042
- // ==================== UTILITIES ====================
1043
-
1044
- _emit(event, detail = {}) {
1045
- // Call registered handlers
1046
- (this._listeners[event] || new Set()).forEach(fn => {
1047
- try { fn(detail); } catch (err) { console.warn('DSSelect event error:', err); }
1048
- });
1049
-
1050
- // Dispatch DOM event
1051
- this.wrapper.dispatchEvent(new CustomEvent(`dsselect:${event}`, {
1052
- bubbles: true,
1053
- detail: { instance: this, ...detail }
1054
- }));
1055
- }
1056
-
1057
- _escapeHtml(str) {
1058
- const div = document.createElement('div');
1059
- div.textContent = str;
1060
- return div.innerHTML;
1061
- }
1062
- }
1063
-
1064
- // Auto-init on DOM ready
1065
- if (typeof document !== 'undefined') {
1066
- if (document.readyState === 'loading') {
1067
- document.addEventListener('DOMContentLoaded', () => DSSelect.initAll());
1068
- } else {
1069
- DSSelect.initAll();
1070
- }
1071
- }
1
+ /**
2
+ * DSSelect
3
+ *
4
+ * A comprehensive searchable select component with:
5
+ * - Static options, JSON data, and Axios remote fetching
6
+ * - Single and multiple selection modes
7
+ * - Laravel old/current/default value support
8
+ * - Full event system
9
+ * - Multiple instances support
10
+ */
11
+ export class DSSelect {
12
+ static instances = new Map();
13
+ static instanceCounter = 0;
14
+
15
+ /**
16
+ * Default Icons
17
+ */
18
+ static icons = {
19
+ search: `<svg class="w-4 h-4 text-base-content/50" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>`,
20
+ chevron: `<svg class="w-4 h-4 text-base-content/50 transition-transform duration-200" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>`,
21
+ close: `<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>`,
22
+ loading: `<span class="loading loading-spinner loading-sm"></span>`,
23
+ check: `<svg class="w-4 h-4 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>`,
24
+ clear: `<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>`
25
+ };
26
+
27
+ /**
28
+ * Default configuration
29
+ */
30
+ static defaults = {
31
+ // Data sources
32
+ options: [], // [{id: 1, name: 'Option'}] or {1: 'Option'}
33
+ axiosUrl: null, // Remote data URL
34
+ axiosMethod: 'GET', // HTTP method for remote
35
+ axiosParams: {}, // Additional params
36
+ axiosSearchParam: 'search', // Search query param name
37
+ axiosDataPath: 'data', // Path to options in response
38
+
39
+ // Value configuration
40
+ valueKey: 'id', // Key for option value
41
+ labelKey: 'name', // Key for option label
42
+
43
+ // Selection
44
+ multiple: false, // Allow multiple selection
45
+ maxSelections: null, // Max items in multiple mode
46
+
47
+ // Search
48
+ searchable: true, // Enable search input
49
+ searchMinLength: 0, // Min chars before search
50
+ searchDebounce: 300, // Debounce delay (ms)
51
+
52
+ // UI
53
+ placeholder: 'Select...',
54
+ searchPlaceholder: 'Type to search...',
55
+ noResultsText: 'No results found',
56
+ loadingText: 'Loading...',
57
+ clearable: true, // Show clear button
58
+ disabled: false,
59
+
60
+ // Dropdown
61
+ closeOnSelect: true, // Close after selection (single mode)
62
+ openOnFocus: true, // Open when input focused
63
+ maxHeight: '240px', // Dropdown max height
64
+
65
+ // Classes
66
+ wrapperClass: '',
67
+ inputClass: '',
68
+ dropdownClass: '',
69
+ optionClass: '',
70
+
71
+ // Translations (for i18n)
72
+ translations: {
73
+ noResults: 'No results found',
74
+ loading: 'Loading...',
75
+ maxSelected: 'Maximum {max} items allowed'
76
+ }
77
+ };
78
+
79
+ /**
80
+ * @param {string|HTMLElement} selector - Container element or selector
81
+ * @param {Object} config - Configuration options
82
+ */
83
+ constructor(selector, config = {}) {
84
+ this.instanceId = `ds-select-${++DSSelect.instanceCounter}`;
85
+ this.wrapper = typeof selector === 'string'
86
+ ? document.querySelector(selector)
87
+ : selector;
88
+
89
+ if (!this.wrapper) {
90
+ throw new Error('DSSelect: Container element not found.');
91
+ }
92
+
93
+ // Merge config with data attributes and defaults
94
+ this.cfg = this._buildConfig(config);
95
+
96
+ // State
97
+ this._options = [];
98
+ this._filteredOptions = [];
99
+ this._selected = this.cfg.multiple ? [] : null;
100
+ this._isOpen = false;
101
+ this._isLoading = false;
102
+ this._searchTerm = '';
103
+ this._highlightedIndex = -1;
104
+
105
+ // Debounce timer
106
+ this._searchTimer = null;
107
+
108
+ // Event listeners storage for cleanup
109
+ this._listeners = {};
110
+ this._boundHandlers = {};
111
+
112
+ // Initialize
113
+ this._init();
114
+
115
+ // Register instance
116
+ DSSelect.instances.set(this.instanceId, this);
117
+ this.wrapper.dataset.dsSelectId = this.instanceId;
118
+ }
119
+
120
+ /**
121
+ * Static factory method
122
+ */
123
+ static create(selector, config = {}) {
124
+ return new DSSelect(selector, config);
125
+ }
126
+
127
+ /**
128
+ * Get instance by element
129
+ */
130
+ static getInstance(element) {
131
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
132
+ if (!el) return null;
133
+ const id = el.dataset.dsSelectId;
134
+ return id ? DSSelect.instances.get(id) : null;
135
+ }
136
+
137
+ /**
138
+ * Auto-initialize all elements with [data-ds-select]
139
+ */
140
+ static initAll(selector = '[data-ds-select]') {
141
+ document.querySelectorAll(selector).forEach(el => {
142
+ if (!el.dataset.dsSelectId) {
143
+ new DSSelect(el);
144
+ }
145
+ });
146
+ }
147
+
148
+ // ==================== INITIALIZATION ====================
149
+
150
+ _buildConfig(userConfig) {
151
+ const dataConfig = this._parseDataAttributes();
152
+ return { ...DSSelect.defaults, ...dataConfig, ...userConfig };
153
+ }
154
+
155
+ _parseDataAttributes() {
156
+ const data = this.wrapper.dataset;
157
+ const config = {};
158
+
159
+ // Parse known data attributes
160
+ if (data.options) {
161
+ try { config.options = JSON.parse(data.options); } catch { }
162
+ }
163
+ if (data.axiosUrl) config.axiosUrl = data.axiosUrl;
164
+ if (data.axiosMethod) config.axiosMethod = data.axiosMethod;
165
+ if (data.multiple !== undefined) config.multiple = data.multiple === 'true';
166
+ if (data.placeholder) config.placeholder = data.placeholder;
167
+ if (data.searchPlaceholder) config.searchPlaceholder = data.searchPlaceholder;
168
+ if (data.valueKey) config.valueKey = data.valueKey;
169
+ if (data.labelKey) config.labelKey = data.labelKey;
170
+ if (data.clearable !== undefined) config.clearable = data.clearable === 'true';
171
+ if (data.disabled !== undefined) config.disabled = data.disabled === 'true';
172
+ if (data.maxSelections) config.maxSelections = parseInt(data.maxSelections, 10);
173
+ if (data.value) {
174
+ try {
175
+ config.initialValue = JSON.parse(data.value);
176
+ } catch {
177
+ config.initialValue = data.value;
178
+ }
179
+ }
180
+
181
+ return config;
182
+ }
183
+
184
+ _init() {
185
+ this._buildDOM();
186
+ this._cacheElements();
187
+ this._loadInitialOptions();
188
+ this._setInitialValue();
189
+ this._bindEvents();
190
+
191
+ // Ensure UI state is correct (especially for empty multiple selects)
192
+ this._updateUI();
193
+
194
+ if (this.cfg.disabled) {
195
+ this.disable();
196
+ }
197
+ }
198
+
199
+ _buildDOM() {
200
+ // Get existing hidden input if any (for ID)
201
+ const existingInput = this.wrapper.querySelector('input[type="hidden"]');
202
+
203
+ // Always use data-name as the base name, strip any existing [] to prevent double-brackets on hot reload
204
+ let baseName = this.wrapper.dataset.name || 'select';
205
+ baseName = baseName.replace(/\[\]$/, ''); // Remove trailing [] if present
206
+
207
+ const inputName = baseName + (this.cfg.multiple ? '[]' : '');
208
+ const inputId = existingInput?.id || this.wrapper.dataset.id || this.instanceId;
209
+
210
+ this.wrapper.classList.add('ds-select-wrapper', 'relative', 'w-full');
211
+ if (this.cfg.wrapperClass) {
212
+ this.wrapper.classList.add(...this.cfg.wrapperClass.split(' '));
213
+ }
214
+
215
+ this.wrapper.innerHTML = `
216
+ <!-- Hidden Input(s) for form submission -->
217
+ <input type="hidden"
218
+ name="${inputName}"
219
+ id="${inputId}"
220
+ data-ds-select-value>
221
+
222
+ <!-- Main Control -->
223
+ <div class="ds-select-control focus-within:outline-none input input-bordered w-full flex items-center gap-1 min-h-10 cursor-pointer py-2 h-auto pr-2 ${this.cfg.inputClass}"
224
+ data-ds-select-control>
225
+
226
+ <!-- Content Area (Tags + Search) -->
227
+ <div class="ds-select-content flex flex-wrap items-center gap-1 flex-1 min-w-0">
228
+ <!-- Selected Tags (Multiple Mode) -->
229
+ <div class="ds-select-tags contents" data-ds-select-tags></div>
230
+
231
+ <!-- Search Input -->
232
+ <input type="text"
233
+ class="ds-select-search flex-1 min-w-[60px] bg-transparent border-0 outline-none text-sm p-0"
234
+ placeholder="${this.cfg.placeholder}"
235
+ data-ds-select-search
236
+ autocomplete="off"
237
+ ${this.cfg.disabled ? 'disabled' : ''}>
238
+ </div>
239
+
240
+ <!-- Icons Container (Always stays right) -->
241
+ <div class="ds-select-icons flex items-center gap-1 ml-auto flex-shrink-0">
242
+ <button type="button"
243
+ class="ds-select-clear btn btn-ghost btn-xs btn-circle hidden"
244
+ data-ds-select-clear
245
+ tabindex="-1">
246
+ ${DSSelect.icons.clear}
247
+ </button>
248
+ <span class="ds-select-loading hidden" data-ds-select-loading>
249
+ ${DSSelect.icons.loading}
250
+ </span>
251
+ <span class="ds-select-chevron" data-ds-select-chevron>
252
+ ${DSSelect.icons.chevron}
253
+ </span>
254
+ </div>
255
+ </div>
256
+
257
+ <!-- Dropdown -->
258
+ <div class="ds-select-dropdown absolute left-0 right-0 top-full mt-1 z-50 hidden"
259
+ data-ds-select-dropdown>
260
+ <ul class="ds-select-options bg-base-100 rounded-box shadow-lg border border-base-300 p-2 overflow-y-auto text-sm w-full flex flex-col gap-1 ${this.cfg.dropdownClass}"
261
+ style="max-height: ${this.cfg.maxHeight}"
262
+ data-ds-select-options
263
+ tabindex="-1">
264
+ </ul>
265
+ </div>
266
+ `;
267
+ }
268
+
269
+ _cacheElements() {
270
+ this.elements = {
271
+ hiddenInput: this.wrapper.querySelector('[data-ds-select-value]'),
272
+ control: this.wrapper.querySelector('[data-ds-select-control]'),
273
+ tags: this.wrapper.querySelector('[data-ds-select-tags]'),
274
+ search: this.wrapper.querySelector('[data-ds-select-search]'),
275
+ clear: this.wrapper.querySelector('[data-ds-select-clear]'),
276
+ loading: this.wrapper.querySelector('[data-ds-select-loading]'),
277
+ chevron: this.wrapper.querySelector('[data-ds-select-chevron]'),
278
+ dropdown: this.wrapper.querySelector('[data-ds-select-dropdown]'),
279
+ options: this.wrapper.querySelector('[data-ds-select-options]')
280
+ };
281
+ }
282
+
283
+ _loadInitialOptions() {
284
+ // Priority: config options > data-options > axios
285
+ if (this.cfg.options && (Array.isArray(this.cfg.options) ? this.cfg.options.length : Object.keys(this.cfg.options).length)) {
286
+ this._options = this._normalizeOptions(this.cfg.options);
287
+ this._filteredOptions = [...this._options];
288
+ } else if (this.cfg.axiosUrl) {
289
+ this._fetchRemoteOptions();
290
+ }
291
+ }
292
+
293
+ _normalizeOptions(options) {
294
+ // Handle array of objects
295
+ if (Array.isArray(options)) {
296
+ return options.map(opt => {
297
+ if (typeof opt === 'object') {
298
+ return {
299
+ value: String(opt[this.cfg.valueKey] ?? opt.id ?? opt.value),
300
+ label: opt[this.cfg.labelKey] ?? opt.name ?? opt.label ?? opt.text,
301
+ data: opt
302
+ };
303
+ }
304
+ return { value: String(opt), label: String(opt), data: opt };
305
+ });
306
+ }
307
+
308
+ // Handle object (key-value pairs)
309
+ if (typeof options === 'object') {
310
+ return Object.entries(options).map(([key, value]) => ({
311
+ value: String(key),
312
+ label: String(value),
313
+ data: { [this.cfg.valueKey]: key, [this.cfg.labelKey]: value }
314
+ }));
315
+ }
316
+
317
+ return [];
318
+ }
319
+
320
+ _setInitialValue() {
321
+ // Priority: old (Laravel) > current > default > config
322
+ const oldValue = this.wrapper.dataset.oldValue;
323
+ const currentValue = this.wrapper.dataset.currentValue;
324
+ const defaultValue = this.wrapper.dataset.defaultValue;
325
+ const configValue = this.cfg.initialValue;
326
+
327
+ let value = oldValue ?? currentValue ?? defaultValue ?? configValue;
328
+
329
+ if (value !== undefined && value !== null && value !== '') {
330
+ if (this.cfg.multiple) {
331
+ let values;
332
+ if (Array.isArray(value)) {
333
+ values = value;
334
+ } else if (typeof value === 'string') {
335
+ // Try to parse as JSON first (for encoded arrays like ["1","2"])
336
+ try {
337
+ const parsed = JSON.parse(value);
338
+ values = Array.isArray(parsed) ? parsed : [parsed];
339
+ } catch {
340
+ // Fallback to comma-separated string
341
+ values = value.split(',');
342
+ }
343
+ } else {
344
+ values = [value];
345
+ }
346
+ values.forEach(v => this._selectValue(String(v).trim(), false));
347
+ } else {
348
+ this._selectValue(String(value), false);
349
+ }
350
+ }
351
+ }
352
+
353
+ // ==================== EVENTS ====================
354
+
355
+ _bindEvents() {
356
+ // Store bound handlers for cleanup
357
+ this._boundHandlers = {
358
+ onControlClick: this._onControlClick.bind(this),
359
+ onSearchInput: this._onSearchInput.bind(this),
360
+ onSearchKeydown: this._onSearchKeydown.bind(this),
361
+ onSearchFocus: this._onSearchFocus.bind(this),
362
+ onClearClick: this._onClearClick.bind(this),
363
+ onDocumentClick: this._onDocumentClick.bind(this),
364
+ onOptionClick: this._onOptionClick.bind(this)
365
+ };
366
+
367
+ // Control click
368
+ this.elements.control.addEventListener('click', this._boundHandlers.onControlClick);
369
+
370
+ // Search input
371
+ this.elements.search.addEventListener('input', this._boundHandlers.onSearchInput);
372
+ this.elements.search.addEventListener('keydown', this._boundHandlers.onSearchKeydown);
373
+ this.elements.search.addEventListener('focus', this._boundHandlers.onSearchFocus);
374
+
375
+ // Clear button
376
+ this.elements.clear.addEventListener('click', this._boundHandlers.onClearClick);
377
+
378
+ // Outside click
379
+ document.addEventListener('click', this._boundHandlers.onDocumentClick);
380
+
381
+ // Options list delegation
382
+ this.elements.options.addEventListener('click', this._boundHandlers.onOptionClick);
383
+ }
384
+
385
+ _onControlClick(e) {
386
+ if (this.cfg.disabled) return;
387
+ if (e.target.closest('[data-ds-select-clear]')) return;
388
+
389
+ this.elements.search.focus();
390
+ if (!this._isOpen) {
391
+ this.open();
392
+ }
393
+ }
394
+
395
+ _onSearchInput(e) {
396
+ const term = e.target.value;
397
+ this._searchTerm = term;
398
+
399
+ this._emit('search', { term });
400
+
401
+ // Debounce search
402
+ clearTimeout(this._searchTimer);
403
+ this._searchTimer = setTimeout(() => {
404
+ this._performSearch(term);
405
+ }, this.cfg.searchDebounce);
406
+ }
407
+
408
+ _onSearchKeydown(e) {
409
+ switch (e.key) {
410
+ case 'ArrowDown':
411
+ e.preventDefault();
412
+ if (!this._isOpen) {
413
+ this.open();
414
+ } else {
415
+ this._highlightNext();
416
+ }
417
+ break;
418
+
419
+ case 'ArrowUp':
420
+ e.preventDefault();
421
+ this._highlightPrev();
422
+ break;
423
+
424
+ case 'Enter':
425
+ e.preventDefault();
426
+ if (this._highlightedIndex >= 0 && this._filteredOptions[this._highlightedIndex]) {
427
+ this._selectOption(this._filteredOptions[this._highlightedIndex]);
428
+ }
429
+ break;
430
+
431
+ case 'Escape':
432
+ e.preventDefault();
433
+ this.close();
434
+ break;
435
+
436
+ case 'Backspace':
437
+ if (this.cfg.multiple && !this._searchTerm && this._selected.length > 0) {
438
+ this._deselectValue(this._selected[this._selected.length - 1].value);
439
+ }
440
+ break;
441
+ }
442
+ }
443
+
444
+ _onSearchFocus() {
445
+ if (this.cfg.openOnFocus && !this._isOpen) {
446
+ this.open();
447
+ }
448
+ }
449
+
450
+ _onClearClick(e) {
451
+ e.preventDefault();
452
+ e.stopPropagation();
453
+ this.clear();
454
+ }
455
+
456
+ _onDocumentClick(e) {
457
+ if (!this.wrapper.contains(e.target) && this._isOpen) {
458
+ this.close();
459
+ }
460
+ }
461
+
462
+ _onOptionClick(e) {
463
+ const optionEl = e.target.closest('[data-ds-option-value]');
464
+ if (!optionEl) return;
465
+
466
+ const value = optionEl.dataset.dsOptionValue;
467
+ const option = this._options.find(o => o.value === value);
468
+
469
+ if (option) {
470
+ this._selectOption(option);
471
+ }
472
+ }
473
+
474
+ // ==================== SEARCH ====================
475
+
476
+ _performSearch(term) {
477
+ if (this.cfg.axiosUrl && term.length >= this.cfg.searchMinLength) {
478
+ this._fetchRemoteOptions(term);
479
+ } else {
480
+ this._filterOptions(term);
481
+ }
482
+ }
483
+
484
+ _filterOptions(term) {
485
+ const searchTerm = term.toLowerCase().trim();
486
+
487
+ if (!searchTerm) {
488
+ this._filteredOptions = [...this._options];
489
+ } else {
490
+ this._filteredOptions = this._options.filter(opt =>
491
+ opt.label.toLowerCase().includes(searchTerm)
492
+ );
493
+ }
494
+
495
+ this._highlightedIndex = this._filteredOptions.length > 0 ? 0 : -1;
496
+ this._renderOptions();
497
+ }
498
+
499
+ async _fetchRemoteOptions(searchTerm = '') {
500
+ if (!this.cfg.axiosUrl) return;
501
+
502
+ this._setLoading(true);
503
+
504
+ const params = {
505
+ ...this.cfg.axiosParams,
506
+ [this.cfg.axiosSearchParam]: searchTerm
507
+ };
508
+
509
+ try {
510
+ let response;
511
+
512
+ // Use axios if available, otherwise fetch
513
+ if (window.axios) {
514
+ response = await window.axios({
515
+ method: this.cfg.axiosMethod,
516
+ url: this.cfg.axiosUrl,
517
+ params: this.cfg.axiosMethod.toUpperCase() === 'GET' ? params : undefined,
518
+ data: this.cfg.axiosMethod.toUpperCase() !== 'GET' ? params : undefined
519
+ });
520
+ response = response.data;
521
+ } else {
522
+ const url = new URL(this.cfg.axiosUrl, window.location.origin);
523
+ if (this.cfg.axiosMethod.toUpperCase() === 'GET') {
524
+ Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
525
+ }
526
+
527
+ const fetchOptions = {
528
+ method: this.cfg.axiosMethod,
529
+ headers: {
530
+ 'Accept': 'application/json',
531
+ 'Content-Type': 'application/json',
532
+ 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || ''
533
+ }
534
+ };
535
+
536
+ if (this.cfg.axiosMethod.toUpperCase() !== 'GET') {
537
+ fetchOptions.body = JSON.stringify(params);
538
+ }
539
+
540
+ const res = await fetch(url.toString(), fetchOptions);
541
+ response = await res.json();
542
+ }
543
+
544
+ // Extract data from response
545
+ let data = response;
546
+ if (this.cfg.axiosDataPath) {
547
+ const paths = this.cfg.axiosDataPath.split('.');
548
+ for (const path of paths) {
549
+ data = data?.[path];
550
+ }
551
+ }
552
+
553
+ this._options = this._normalizeOptions(data || []);
554
+ this._filteredOptions = [...this._options];
555
+
556
+ // IMPORTANT: Set loading to false BEFORE rendering so options actually display
557
+ this._setLoading(false);
558
+ this._renderOptions();
559
+
560
+ this._emit('load', { options: this._options });
561
+
562
+ } catch (error) {
563
+ console.error('DSSelect: Failed to fetch options', error);
564
+ this._setLoading(false);
565
+ this._emit('error', { error });
566
+ }
567
+
568
+ }
569
+
570
+ // ==================== SELECTION ====================
571
+
572
+ _selectOption(option) {
573
+ if (this.cfg.multiple) {
574
+ const isSelected = this._selected.some(s => s.value === option.value);
575
+
576
+ if (isSelected) {
577
+ this._deselectValue(option.value);
578
+ } else {
579
+ // Check max selections
580
+ if (this.cfg.maxSelections && this._selected.length >= this.cfg.maxSelections) {
581
+ this._emit('maxReached', { max: this.cfg.maxSelections });
582
+ return;
583
+ }
584
+ this._selected.push(option);
585
+ this._emit('select', { option, selected: this._selected });
586
+ }
587
+
588
+ // Clear search and reset filter in multiple mode
589
+ this.elements.search.value = '';
590
+ this._searchTerm = '';
591
+ this._filteredOptions = [...this._options];
592
+ } else {
593
+ const prevSelected = this._selected;
594
+ this._selected = option;
595
+ this._emit('select', { option, previous: prevSelected });
596
+
597
+ if (this.cfg.closeOnSelect) {
598
+ this.close();
599
+ }
600
+ }
601
+
602
+ this._updateUI();
603
+ this._emit('change', { value: this.getValue(), selected: this._selected });
604
+ }
605
+
606
+ _selectValue(value, triggerChange = true) {
607
+ const option = this._options.find(o => o.value === value);
608
+ if (!option) return false;
609
+
610
+ if (this.cfg.multiple) {
611
+ if (!this._selected.some(s => s.value === value)) {
612
+ this._selected.push(option);
613
+ }
614
+ } else {
615
+ this._selected = option;
616
+ }
617
+
618
+ this._updateUI();
619
+
620
+ if (triggerChange) {
621
+ this._emit('change', { value: this.getValue(), selected: this._selected });
622
+ }
623
+
624
+ return true;
625
+ }
626
+
627
+ _deselectValue(value) {
628
+ if (!this.cfg.multiple) {
629
+ this._selected = null;
630
+ } else {
631
+ const option = this._selected.find(s => s.value === value);
632
+ this._selected = this._selected.filter(s => s.value !== value);
633
+ if (option) {
634
+ this._emit('deselect', { option, selected: this._selected });
635
+ }
636
+ }
637
+
638
+ this._updateUI();
639
+ this._emit('change', { value: this.getValue(), selected: this._selected });
640
+ }
641
+
642
+ // ==================== UI RENDERING ====================
643
+
644
+ _updateUI() {
645
+ this._updateHiddenInput();
646
+ this._updateTags();
647
+ this._updateSearchPlaceholder();
648
+ this._updateClearButton();
649
+ this._renderOptions();
650
+ }
651
+
652
+ _updateHiddenInput() {
653
+ if (this.cfg.multiple) {
654
+ // For multiple, we need multiple hidden inputs
655
+ const name = this.elements.hiddenInput.name;
656
+
657
+ // Remove existing additional inputs
658
+ this.wrapper.querySelectorAll('input[data-ds-select-multi-value]').forEach(el => el.remove());
659
+
660
+ if (this._selected.length === 0) {
661
+ // Disable the hidden input so it won't be submitted (prevents [""] being sent)
662
+ this.elements.hiddenInput.value = '';
663
+ this.elements.hiddenInput.disabled = true;
664
+ } else {
665
+ // Enable and create hidden input for each selected value
666
+ this.elements.hiddenInput.disabled = false;
667
+ this.elements.hiddenInput.value = this._selected[0].value;
668
+
669
+ this._selected.slice(1).forEach(opt => {
670
+ const input = document.createElement('input');
671
+ input.type = 'hidden';
672
+ input.name = name;
673
+ input.value = opt.value;
674
+ input.dataset.dsSelectMultiValue = 'true';
675
+ this.wrapper.insertBefore(input, this.elements.control);
676
+ });
677
+ }
678
+ } else {
679
+ this.elements.hiddenInput.value = this._selected?.value || '';
680
+ }
681
+ }
682
+
683
+ _updateTags() {
684
+ if (!this.cfg.multiple) {
685
+ // For single select, show selected value in search input
686
+ this.elements.tags.innerHTML = '';
687
+ if (this._selected && !this._isOpen) {
688
+ this.elements.search.value = this._selected.label;
689
+ }
690
+ return;
691
+ }
692
+
693
+ // Multiple mode - render tags
694
+ this.elements.tags.innerHTML = this._selected.map(opt => `
695
+ <span class="ds-select-tag badge badge-primary gap-1 py-3">
696
+ <span class="text-xs">${this._escapeHtml(opt.label)}</span>
697
+ <button type="button"
698
+ class="btn btn-ghost btn-xs btn-circle -mr-1"
699
+ data-ds-tag-remove="${opt.value}"
700
+ tabindex="-1">
701
+ ${DSSelect.icons.close}
702
+ </button>
703
+ </span>
704
+ `).join('');
705
+
706
+ // Bind remove handlers
707
+ this.elements.tags.querySelectorAll('[data-ds-tag-remove]').forEach(btn => {
708
+ btn.addEventListener('click', (e) => {
709
+ e.stopPropagation();
710
+ this._deselectValue(btn.dataset.dsTagRemove);
711
+ this.elements.search.focus();
712
+ });
713
+ });
714
+ }
715
+
716
+ _updateSearchPlaceholder() {
717
+ if (this.cfg.multiple) {
718
+ this.elements.search.placeholder = this._selected.length > 0
719
+ ? ''
720
+ : this.cfg.placeholder;
721
+ } else {
722
+ this.elements.search.placeholder = this._selected
723
+ ? ''
724
+ : this.cfg.placeholder;
725
+ }
726
+ }
727
+
728
+ _updateClearButton() {
729
+ const hasValue = this.cfg.multiple
730
+ ? this._selected.length > 0
731
+ : this._selected !== null;
732
+
733
+ if (this.cfg.clearable && hasValue) {
734
+ this.elements.clear.classList.remove('hidden');
735
+ } else {
736
+ this.elements.clear.classList.add('hidden');
737
+ }
738
+ }
739
+
740
+ _renderOptions() {
741
+ if (this._isLoading) {
742
+ this.elements.options.innerHTML = `
743
+ <li class="disabled text-center py-4 text-base-content/50">
744
+ ${DSSelect.icons.loading}
745
+ <span class="ml-2">${this.cfg.loadingText}</span>
746
+ </li>
747
+ `;
748
+ return;
749
+ }
750
+
751
+ if (this._filteredOptions.length === 0) {
752
+ this.elements.options.innerHTML = `
753
+ <li class="disabled text-center py-4 text-base-content/50">
754
+ ${this.cfg.noResultsText}
755
+ </li>
756
+ `;
757
+ return;
758
+ }
759
+
760
+ this.elements.options.innerHTML = this._filteredOptions.map((opt, idx) => {
761
+ const isSelected = this.cfg.multiple
762
+ ? this._selected.some(s => s.value === opt.value)
763
+ : this._selected?.value === opt.value;
764
+ const isHighlighted = idx === this._highlightedIndex;
765
+
766
+ return `
767
+ <li class="w-full">
768
+ <a class="ds-select-option flex items-center justify-between gap-2 w-full px-3 py-2 rounded-lg cursor-pointer hover:bg-base-200 ${isSelected ? 'bg-primary/10 text-primary' : ''} ${isHighlighted ? 'bg-base-200' : ''} ${this.cfg.optionClass}"
769
+ data-ds-option-value="${this._escapeHtml(opt.value)}"
770
+ data-ds-option-index="${idx}">
771
+ <span>${this._escapeHtml(opt.label)}</span>
772
+ ${isSelected ? DSSelect.icons.check : ''}
773
+ </a>
774
+ </li>
775
+ `;
776
+ }).join('');
777
+ }
778
+
779
+ _highlightNext() {
780
+ if (this._filteredOptions.length === 0) return;
781
+
782
+ this._highlightedIndex = Math.min(
783
+ this._highlightedIndex + 1,
784
+ this._filteredOptions.length - 1
785
+ );
786
+ this._updateHighlight();
787
+ }
788
+
789
+ _highlightPrev() {
790
+ if (this._filteredOptions.length === 0) return;
791
+
792
+ this._highlightedIndex = Math.max(this._highlightedIndex - 1, 0);
793
+ this._updateHighlight();
794
+ }
795
+
796
+ _updateHighlight() {
797
+ this.elements.options.querySelectorAll('.ds-select-option').forEach((el, idx) => {
798
+ el.classList.toggle('focus', idx === this._highlightedIndex);
799
+ });
800
+
801
+ // Scroll into view
802
+ const highlighted = this.elements.options.querySelector('.focus');
803
+ if (highlighted) {
804
+ highlighted.scrollIntoView({ block: 'nearest' });
805
+ }
806
+ }
807
+
808
+ _setLoading(loading) {
809
+ this._isLoading = loading;
810
+ this.elements.loading.classList.toggle('hidden', !loading);
811
+ this.elements.chevron.classList.toggle('hidden', loading);
812
+ }
813
+
814
+ // ==================== PUBLIC API ====================
815
+
816
+ /**
817
+ * Open the dropdown
818
+ */
819
+ open() {
820
+ if (this._isOpen || this.cfg.disabled) return;
821
+
822
+ this._isOpen = true;
823
+ this.elements.dropdown.classList.remove('hidden');
824
+ this.elements.chevron.querySelector('svg')?.classList.add('rotate-180');
825
+
826
+ // Reset search for single mode
827
+ if (!this.cfg.multiple && this._selected) {
828
+ this.elements.search.value = '';
829
+ }
830
+
831
+ this._filteredOptions = [...this._options];
832
+ this._highlightedIndex = 0;
833
+ this._renderOptions();
834
+
835
+ this._emit('open');
836
+ }
837
+
838
+ /**
839
+ * Close the dropdown
840
+ */
841
+ close() {
842
+ if (!this._isOpen) return;
843
+
844
+ this._isOpen = false;
845
+ this.elements.dropdown.classList.add('hidden');
846
+ this.elements.chevron.querySelector('svg')?.classList.remove('rotate-180');
847
+
848
+ // Restore selection display for single mode
849
+ if (!this.cfg.multiple) {
850
+ this.elements.search.value = this._selected?.label || '';
851
+ }
852
+
853
+ this._searchTerm = '';
854
+
855
+ this._emit('close');
856
+ }
857
+
858
+ /**
859
+ * Toggle dropdown
860
+ */
861
+ toggle() {
862
+ this._isOpen ? this.close() : this.open();
863
+ }
864
+
865
+ /**
866
+ * Get current value(s)
867
+ */
868
+ getValue() {
869
+ if (this.cfg.multiple) {
870
+ return this._selected.map(s => s.value);
871
+ }
872
+ return this._selected?.value || null;
873
+ }
874
+
875
+ /**
876
+ * Get selected option(s) with full data
877
+ */
878
+ getSelected() {
879
+ return this._selected;
880
+ }
881
+
882
+ /**
883
+ * Set value(s)
884
+ */
885
+ setValue(value) {
886
+ if (this.cfg.multiple) {
887
+ this._selected = [];
888
+ const values = Array.isArray(value) ? value : [value];
889
+ values.forEach(v => this._selectValue(String(v), false));
890
+ } else {
891
+ this._selected = null;
892
+ if (value !== null && value !== undefined) {
893
+ this._selectValue(String(value), false);
894
+ }
895
+ }
896
+ this._updateUI();
897
+ this._emit('change', { value: this.getValue(), selected: this._selected });
898
+ }
899
+
900
+ /**
901
+ * Clear all selections
902
+ */
903
+ clear() {
904
+ if (this.cfg.multiple) {
905
+ this._selected = [];
906
+ } else {
907
+ this._selected = null;
908
+ }
909
+
910
+ this.elements.search.value = '';
911
+ this._searchTerm = '';
912
+ this._updateUI();
913
+
914
+ this._emit('clear');
915
+ this._emit('change', { value: this.getValue(), selected: this._selected });
916
+ }
917
+
918
+ /**
919
+ * Reset to initial value
920
+ */
921
+ reset() {
922
+ this.clear();
923
+ this._setInitialValue();
924
+ this._emit('reset');
925
+ }
926
+
927
+ /**
928
+ * Add option(s)
929
+ */
930
+ addOption(option) {
931
+ const normalized = this._normalizeOptions(Array.isArray(option) ? option : [option]);
932
+ this._options.push(...normalized);
933
+ this._filteredOptions = [...this._options];
934
+ this._renderOptions();
935
+ }
936
+
937
+ /**
938
+ * Remove option by value
939
+ */
940
+ removeOption(value) {
941
+ this._options = this._options.filter(o => o.value !== String(value));
942
+ this._filteredOptions = this._filteredOptions.filter(o => o.value !== String(value));
943
+
944
+ // Also deselect if selected
945
+ if (this.cfg.multiple) {
946
+ this._selected = this._selected.filter(s => s.value !== String(value));
947
+ } else if (this._selected?.value === String(value)) {
948
+ this._selected = null;
949
+ }
950
+
951
+ this._updateUI();
952
+ }
953
+
954
+ /**
955
+ * Set options (replace all)
956
+ */
957
+ setOptions(options) {
958
+ this._options = this._normalizeOptions(options);
959
+ this._filteredOptions = [...this._options];
960
+ this._renderOptions();
961
+ }
962
+
963
+ /**
964
+ * Refresh/reload options from remote
965
+ */
966
+ refresh() {
967
+ if (this.cfg.axiosUrl) {
968
+ this._fetchRemoteOptions(this._searchTerm);
969
+ }
970
+ }
971
+
972
+ /**
973
+ * Enable the select
974
+ */
975
+ enable() {
976
+ this.cfg.disabled = false;
977
+ this.elements.search.disabled = false;
978
+ this.elements.control.classList.remove('opacity-50', 'cursor-not-allowed');
979
+ this._emit('enable');
980
+ }
981
+
982
+ /**
983
+ * Disable the select
984
+ */
985
+ disable() {
986
+ this.cfg.disabled = true;
987
+ this.elements.search.disabled = true;
988
+ this.elements.control.classList.add('opacity-50', 'cursor-not-allowed');
989
+ this.close();
990
+ this._emit('disable');
991
+ }
992
+
993
+ /**
994
+ * Subscribe to events
995
+ */
996
+ on(event, handler) {
997
+ if (!this._listeners[event]) {
998
+ this._listeners[event] = new Set();
999
+ }
1000
+ this._listeners[event].add(handler);
1001
+ return this;
1002
+ }
1003
+
1004
+ /**
1005
+ * Unsubscribe from events
1006
+ */
1007
+ off(event, handler) {
1008
+ if (this._listeners[event]) {
1009
+ if (handler) {
1010
+ this._listeners[event].delete(handler);
1011
+ } else {
1012
+ this._listeners[event].clear();
1013
+ }
1014
+ }
1015
+ return this;
1016
+ }
1017
+
1018
+ /**
1019
+ * Destroy instance
1020
+ */
1021
+ destroy() {
1022
+ // Remove event listeners
1023
+ this.elements.control.removeEventListener('click', this._boundHandlers.onControlClick);
1024
+ this.elements.search.removeEventListener('input', this._boundHandlers.onSearchInput);
1025
+ this.elements.search.removeEventListener('keydown', this._boundHandlers.onSearchKeydown);
1026
+ this.elements.search.removeEventListener('focus', this._boundHandlers.onSearchFocus);
1027
+ this.elements.clear.removeEventListener('click', this._boundHandlers.onClearClick);
1028
+ document.removeEventListener('click', this._boundHandlers.onDocumentClick);
1029
+ this.elements.options.removeEventListener('click', this._boundHandlers.onOptionClick);
1030
+
1031
+ // Clear state
1032
+ this._listeners = {};
1033
+ clearTimeout(this._searchTimer);
1034
+
1035
+ // Remove from instances
1036
+ DSSelect.instances.delete(this.instanceId);
1037
+ delete this.wrapper.dataset.dsSelectId;
1038
+
1039
+ this._emit('destroy');
1040
+ }
1041
+
1042
+ // ==================== UTILITIES ====================
1043
+
1044
+ _emit(event, detail = {}) {
1045
+ // Call registered handlers
1046
+ (this._listeners[event] || new Set()).forEach(fn => {
1047
+ try { fn(detail); } catch (err) { console.warn('DSSelect event error:', err); }
1048
+ });
1049
+
1050
+ // Dispatch DOM event
1051
+ this.wrapper.dispatchEvent(new CustomEvent(`dsselect:${event}`, {
1052
+ bubbles: true,
1053
+ detail: { instance: this, ...detail }
1054
+ }));
1055
+ }
1056
+
1057
+ _escapeHtml(str) {
1058
+ const div = document.createElement('div');
1059
+ div.textContent = str;
1060
+ return div.innerHTML;
1061
+ }
1062
+ }
1063
+
1064
+ // Auto-init on DOM ready
1065
+ if (typeof document !== 'undefined') {
1066
+ if (document.readyState === 'loading') {
1067
+ document.addEventListener('DOMContentLoaded', () => DSSelect.initAll());
1068
+ } else {
1069
+ DSSelect.initAll();
1070
+ }
1071
+ }