@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.
- package/LICENSE +1 -1
- package/package.json +1 -1
- package/src/CodeInput.js +48 -48
- package/src/DSAlert.js +352 -352
- package/src/DSAvatar.js +207 -207
- package/src/DSDelete.js +274 -274
- package/src/DSForm.js +568 -568
- package/src/DSGridOrTable.js +453 -453
- package/src/DSLocaleSwitcher.js +239 -239
- package/src/DSLogout.js +293 -293
- package/src/DSNotifications.js +365 -365
- package/src/DSRestore.js +181 -181
- package/src/DSSelect.js +1071 -1071
- package/src/DSSelectBox.js +563 -563
- package/src/DSSimpleSlider.js +517 -517
- package/src/DSSvgFetch.js +69 -69
- package/src/DSTable/DSTableExport.js +68 -68
- package/src/DSTable/DSTableFilter.js +224 -224
- package/src/DSTable/DSTablePagination.js +136 -136
- package/src/DSTable/DSTableSearch.js +40 -40
- package/src/DSTable/DSTableSelection.js +192 -192
- package/src/DSTable/DSTableSort.js +58 -58
- package/src/DSTable.js +353 -353
- package/src/DSTabs.js +488 -488
- package/src/DSUpload.js +887 -887
- package/dist/CodeInput.d.ts +0 -10
- package/dist/DSAlert.d.ts +0 -112
- package/dist/DSAvatar.d.ts +0 -45
- package/dist/DSDelete.d.ts +0 -61
- package/dist/DSForm.d.ts +0 -151
- package/dist/DSGridOrTable/DSGOTRenderer.d.ts +0 -60
- package/dist/DSGridOrTable/DSGOTViewToggle.d.ts +0 -26
- package/dist/DSGridOrTable.d.ts +0 -296
- package/dist/DSLocaleSwitcher.d.ts +0 -71
- package/dist/DSLogout.d.ts +0 -76
- package/dist/DSNotifications.d.ts +0 -54
- package/dist/DSRestore.d.ts +0 -56
- package/dist/DSSelect.d.ts +0 -221
- package/dist/DSSelectBox.d.ts +0 -123
- package/dist/DSSimpleSlider.d.ts +0 -136
- package/dist/DSSvgFetch.d.ts +0 -17
- package/dist/DSTable/DSTableExport.d.ts +0 -11
- package/dist/DSTable/DSTableFilter.d.ts +0 -40
- package/dist/DSTable/DSTablePagination.d.ts +0 -12
- package/dist/DSTable/DSTableSearch.d.ts +0 -8
- package/dist/DSTable/DSTableSelection.d.ts +0 -46
- package/dist/DSTable/DSTableSort.d.ts +0 -8
- package/dist/DSTable.d.ts +0 -116
- package/dist/DSTabs.d.ts +0 -156
- package/dist/DSUpload.d.ts +0 -220
- 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
|
+
}
|