@innertia-solutions/ui 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,726 @@
1
+ <script setup>
2
+ // Props del componente
3
+ const props = defineProps({
4
+ // Endpoint para la carga desde API
5
+ endpoint: { type: String, required: true },
6
+ listFormat: { type: Boolean, default: true },
7
+ perPage: { type: Number, default: 15 },
8
+ initialOptions: { type: Array, default: () => [] },
9
+
10
+ // v-model
11
+ modelValue: { type: [String, Number, Array, Object], default: null },
12
+
13
+ // Form wrapper
14
+ label: { type: String, default: "" },
15
+ placeholder: { type: String, default: "Seleccionar..." },
16
+ hint: { type: String, default: "" },
17
+ error: { type: String, default: "" },
18
+
19
+ // Functional
20
+ multiple: { type: Boolean, default: false },
21
+ searchable: { type: Boolean, default: true },
22
+ disabled: { type: Boolean, default: false },
23
+ clearable: { type: Boolean, default: false },
24
+ searchPlaceholder: { type: String, default: "Buscar..." },
25
+ minSearchLength: { type: Number, default: 0 },
26
+ tagsMode: { type: Boolean, default: false },
27
+ showCounter: { type: Boolean, default: false },
28
+ maxSelection: { type: Number, default: 0 },
29
+ allowEmpty: { type: Boolean, default: true },
30
+ closeOnSelect: { type: Boolean, default: true },
31
+
32
+ // Claves de campo
33
+ labelKey: { type: String, default: "name" },
34
+ valueKey: { type: String, default: "id" },
35
+ descriptionKey: { type: String, default: "description" },
36
+ });
37
+
38
+ // Emits
39
+ const emit = defineEmits(["update:modelValue", "blur", "change", "search", "clear"]);
40
+
41
+ // Reactive state
42
+ const isOpen = ref(false);
43
+ const searchQuery = ref("");
44
+ const selectRef = ref(null);
45
+ const optionsListRef = ref(null);
46
+ const selectedOptions = ref([]);
47
+ const focusedIndex = ref(-1);
48
+
49
+ // SSR Data
50
+ const isFetching = ref(false);
51
+ const currentPage = ref(1);
52
+ const hasMorePages = ref(true);
53
+ const serverOptions = ref([...props.initialOptions]);
54
+
55
+ const fetchOptions = async (page = 1, query = "") => {
56
+ if (isFetching.value || (!hasMorePages.value && page > 1)) return;
57
+
58
+ isFetching.value = true;
59
+ currentPage.value = page;
60
+
61
+ try {
62
+ const params = new URLSearchParams({
63
+ page: page.toString(),
64
+ perPage: props.perPage.toString(),
65
+ });
66
+
67
+ if (query) {
68
+ params.append('search', query);
69
+ }
70
+
71
+ if (props.listFormat) {
72
+ params.append('list', 'true');
73
+ }
74
+
75
+ const api = useApi();
76
+ const { data, error } = await api.get(`${props.endpoint}?${params.toString()}`);
77
+
78
+ if (!error && data) {
79
+ let newItems = [];
80
+ let meta = null;
81
+
82
+ if (data.data && Array.isArray(data.data)) {
83
+ newItems = data.data;
84
+ meta = data.meta;
85
+ } else if (Array.isArray(data)) {
86
+ newItems = data;
87
+ }
88
+
89
+ if (page === 1) {
90
+ serverOptions.value = newItems;
91
+ hasMorePages.value = meta ? meta.current_page < meta.last_page : newItems.length >= props.perPage;
92
+ } else {
93
+ const existingIds = new Set(serverOptions.value.map(opt => opt.id));
94
+ const nonDuplicateItems = newItems.filter(opt => !existingIds.has(opt.id));
95
+ serverOptions.value = [...serverOptions.value, ...nonDuplicateItems];
96
+
97
+ if (nonDuplicateItems.length === 0 && newItems.length > 0) {
98
+ hasMorePages.value = false;
99
+ } else {
100
+ hasMorePages.value = meta ? meta.current_page < meta.last_page : newItems.length >= props.perPage;
101
+ }
102
+ }
103
+ }
104
+ } catch (err) {
105
+ // silently handle fetch errors
106
+ } finally {
107
+ isFetching.value = false;
108
+ }
109
+ };
110
+
111
+ let debounceTimer = null;
112
+ const handleSearchInput = (event) => {
113
+ searchQuery.value = event.target.value;
114
+ emit("search", searchQuery.value);
115
+
116
+ if (debounceTimer) clearTimeout(debounceTimer);
117
+
118
+ debounceTimer = setTimeout(() => {
119
+ if (searchQuery.value.length === 0 || searchQuery.value.length >= props.minSearchLength) {
120
+ fetchOptions(1, searchQuery.value);
121
+ if (optionsListRef.value) {
122
+ optionsListRef.value.scrollTop = 0;
123
+ }
124
+ }
125
+ }, 300);
126
+ };
127
+
128
+ const clearSearch = () => {
129
+ searchQuery.value = "";
130
+ emit("search", "");
131
+ fetchOptions(1, "");
132
+ };
133
+
134
+ // Infinite scroll
135
+ const handleScroll = (event) => {
136
+ const container = event.target;
137
+ if (container.scrollHeight - container.scrollTop <= container.clientHeight + 50) {
138
+ if (!isFetching.value && hasMorePages.value) {
139
+ fetchOptions(currentPage.value + 1, searchQuery.value);
140
+ }
141
+ }
142
+ };
143
+
144
+ // Initial load
145
+ onMounted(() => {
146
+ if (props.initialOptions && props.initialOptions.length > 0) {
147
+ serverOptions.value = [...props.initialOptions];
148
+ }
149
+ });
150
+
151
+ // Model value handling
152
+ const localValue = computed({
153
+ get() {
154
+ return props.modelValue;
155
+ },
156
+ set(value) {
157
+ emit("update:modelValue", value);
158
+ emit("change", value);
159
+ nextTick(() => {
160
+ if (!props.name || !selectRef?.value) return;
161
+ const hidden = selectRef.value.querySelector(
162
+ `input[type="hidden"][name="${props.name}"]`
163
+ );
164
+ if (!hidden) return;
165
+ hidden.dispatchEvent(new Event('input', { bubbles: true }));
166
+ hidden.dispatchEvent(new Event('change', { bubbles: true }));
167
+ });
168
+ },
169
+ });
170
+
171
+ // Initialize selected options
172
+ watch(
173
+ () => props.modelValue,
174
+ (newValue) => {
175
+ if (!serverOptions.value || serverOptions.value.length === 0) {
176
+ return;
177
+ }
178
+
179
+ try {
180
+ if (props.multiple && Array.isArray(newValue)) {
181
+ selectedOptions.value = newValue
182
+ .map((val) => {
183
+ try {
184
+ return serverOptions.value.find(
185
+ (opt) => getOptionValue(opt) === val || opt.id === val
186
+ );
187
+ } catch (error) {
188
+ return serverOptions.value.find((opt) => opt.id === val);
189
+ }
190
+ })
191
+ .filter(Boolean);
192
+ } else if (!props.multiple && newValue !== null && newValue !== undefined) {
193
+ try {
194
+ const option = serverOptions.value.find(
195
+ (opt) => getOptionValue(opt) === newValue || opt.id === newValue
196
+ );
197
+ selectedOptions.value = option ? [option] : [];
198
+ } catch (error) {
199
+ const option = serverOptions.value.find((opt) => opt.id === newValue);
200
+ selectedOptions.value = option ? [option] : [];
201
+ }
202
+ } else {
203
+ selectedOptions.value = [];
204
+ }
205
+ } catch (error) {
206
+ selectedOptions.value = [];
207
+ }
208
+ },
209
+ { immediate: true }
210
+ );
211
+
212
+ // Watch para cuando las opciones (serverOptions) cambien
213
+ watch(
214
+ () => serverOptions.value,
215
+ (newOptions) => {
216
+ if (newOptions && newOptions.length > 0 && props.modelValue) {
217
+ nextTick(() => {
218
+ const currentValue = props.modelValue;
219
+ if (currentValue) {
220
+ try {
221
+ if (props.multiple && Array.isArray(currentValue)) {
222
+ selectedOptions.value = currentValue
223
+ .map((val) => {
224
+ try {
225
+ const existing = selectedOptions.value.find(s => getOptionValue(s) === val);
226
+ const found = serverOptions.value.find(
227
+ (opt) => getOptionValue(opt) === val || opt.id === val
228
+ );
229
+ return found || existing;
230
+ } catch (error) {
231
+ return serverOptions.value.find((opt) => opt.id === val);
232
+ }
233
+ })
234
+ .filter(Boolean);
235
+ } else if (!props.multiple && currentValue !== null && currentValue !== undefined) {
236
+ try {
237
+ const existing = selectedOptions.value.find(s => getOptionValue(s) === currentValue);
238
+ const found = serverOptions.value.find(
239
+ (opt) => getOptionValue(opt) === currentValue || opt.id === currentValue
240
+ );
241
+ selectedOptions.value = (found || existing) ? [found || existing] : [];
242
+ } catch (error) {
243
+ const found = serverOptions.value.find((opt) => opt.id === currentValue);
244
+ selectedOptions.value = found ? [found] : [];
245
+ }
246
+ }
247
+ } catch (error) {
248
+ // silently handle
249
+ }
250
+ }
251
+ });
252
+ }
253
+ },
254
+ { deep: true }
255
+ );
256
+
257
+ // Helper functions para extraer valores dinámicamente
258
+ const getOptionLabel = (option) => {
259
+ if (props.labelKey === "key" && option.key) {
260
+ return option.key;
261
+ }
262
+ if (props.labelKey === "label" && option.label) {
263
+ return option.label;
264
+ }
265
+ if (props.labelKey === "name" && option.name) {
266
+ return option.name;
267
+ }
268
+ if (props.labelKey.includes(".")) {
269
+ const keys = props.labelKey.split(".");
270
+ let result = option;
271
+ for (const key of keys) {
272
+ result = result?.[key];
273
+ }
274
+ return result || option.key || option.label || option.name || "";
275
+ }
276
+ return (
277
+ option[props.labelKey] ||
278
+ option.label ||
279
+ option.key ||
280
+ option.name ||
281
+ option.value ||
282
+ ""
283
+ );
284
+ };
285
+
286
+ const getOptionValue = (option) => {
287
+ if (props.valueKey === "id" && option.id) {
288
+ return option.id;
289
+ }
290
+ if (props.valueKey === "value" && option.value !== undefined) {
291
+ return option.value;
292
+ }
293
+ if (props.valueKey === "key" && option.key) {
294
+ return option.key;
295
+ }
296
+ if (props.valueKey.includes(".")) {
297
+ const keys = props.valueKey.split(".");
298
+ let result = option;
299
+ for (const key of keys) {
300
+ result = result?.[key];
301
+ }
302
+ return result;
303
+ }
304
+ return option[props.valueKey] || option.value || option.id || option.key;
305
+ };
306
+
307
+ const getOptionDescription = (option) => {
308
+ if (!props.descriptionKey) return "";
309
+ if (props.descriptionKey.includes(".")) {
310
+ const keys = props.descriptionKey.split(".");
311
+ let result = option;
312
+ for (const key of keys) {
313
+ result = result?.[key];
314
+ }
315
+ return result || "";
316
+ }
317
+ return option[props.descriptionKey] || option.description || "";
318
+ };
319
+
320
+ // Computed classes for sizes
321
+ const sizeClasses = computed(() => {
322
+ const sizes = {
323
+ xs: "py-1.5 px-3 text-xs",
324
+ sm: "py-2 px-3 text-sm",
325
+ md: "py-2 px-3 text-sm",
326
+ lg: "py-3 px-3 text-base",
327
+ };
328
+ return sizes[props.size] || sizes.sm;
329
+ });
330
+
331
+ // Computed classes for validation states
332
+ const validationClasses = computed(() => {
333
+ if (props.error) return "border-red-400 dark:border-red-500";
334
+ return "border-slate-200 dark:border-slate-700";
335
+ });
336
+
337
+ // Combined select classes
338
+ const selectClasses = computed(() => {
339
+ let base, focus, disabled;
340
+
341
+ base =
342
+ "relative w-full rounded-lg border bg-white dark:bg-slate-800 transition-colors cursor-pointer text-slate-900 dark:text-white";
343
+
344
+ if (props.multiple && props.tagsMode) {
345
+ base += " px-3 pe-8 min-h-[2.375rem] flex items-center flex-wrap text-nowrap";
346
+ focus = "focus:outline-none focus:border-gray-400";
347
+ } else {
348
+ focus = "focus:outline-none focus:ring-0 focus:border-gray-400";
349
+ }
350
+
351
+ disabled = props.disabled || props.loading ? "opacity-50 cursor-not-allowed" : "";
352
+
353
+ const sizeClass = props.multiple && props.tagsMode ? "" : sizeClasses.value;
354
+
355
+ return `${base} ${sizeClass} ${validationClasses.value} ${focus} ${disabled} ${props.class}`;
356
+ });
357
+
358
+ // Filtered options — backend does the filtering, serverOptions IS the filteredOptions
359
+ const filteredOptions = computed(() => {
360
+ return serverOptions.value;
361
+ });
362
+
363
+ // Display text for selected values
364
+ const displayText = computed(() => {
365
+ if (props.loading) return "Cargando...";
366
+
367
+ if (!selectedOptions.value.length) return props.placeholder;
368
+
369
+ if (props.multiple) {
370
+ if (props.showCounter && selectedOptions.value.length > 1) {
371
+ return `${selectedOptions.value.length} seleccionados`;
372
+ }
373
+ if (props.tagsMode) {
374
+ return selectedOptions.value.map((opt) => getOptionLabel(opt)).join(", ");
375
+ }
376
+ return selectedOptions.value.map((opt) => getOptionLabel(opt)).join(", ");
377
+ }
378
+
379
+ return getOptionLabel(selectedOptions.value[0]) || props.placeholder;
380
+ });
381
+
382
+ const toggleSelect = async () => {
383
+ if (props.disabled || props.loading) return;
384
+ isOpen.value = !isOpen.value;
385
+
386
+ if (isOpen.value) {
387
+ if (serverOptions.value.length <= (props.initialOptions?.length || 0) && !searchQuery.value && !isFetching.value) {
388
+ await fetchOptions(1);
389
+ }
390
+
391
+ if (props.searchable) {
392
+ nextTick(() => {
393
+ const searchInput = selectRef.value?.querySelector(
394
+ 'input[type="search"]'
395
+ );
396
+ searchInput?.focus();
397
+ });
398
+ }
399
+ }
400
+ };
401
+
402
+ const selectOption = (option) => {
403
+ if (option.disabled) return;
404
+
405
+ if (props.multiple) {
406
+ const index = selectedOptions.value.findIndex(
407
+ (opt) => opt.id === option.id
408
+ );
409
+ if (index > -1) {
410
+ selectedOptions.value.splice(index, 1);
411
+ } else {
412
+ if (
413
+ props.maxSelection === 0 ||
414
+ selectedOptions.value.length < props.maxSelection
415
+ ) {
416
+ selectedOptions.value.push(option);
417
+ }
418
+ }
419
+ localValue.value = selectedOptions.value.map((opt) => getOptionValue(opt));
420
+ } else {
421
+ selectedOptions.value = [option];
422
+ localValue.value = getOptionValue(option);
423
+ if (props.closeOnSelect) {
424
+ isOpen.value = false;
425
+ }
426
+ }
427
+ };
428
+
429
+ const removeTag = (option) => {
430
+ if (props.disabled) return;
431
+ const index = selectedOptions.value.findIndex((opt) => opt.id === option.id);
432
+ if (index > -1) {
433
+ selectedOptions.value.splice(index, 1);
434
+ localValue.value = selectedOptions.value.map((opt) => getOptionValue(opt));
435
+ }
436
+ };
437
+
438
+ const clearSelection = () => {
439
+ if (props.disabled) return;
440
+ selectedOptions.value = [];
441
+ localValue.value = props.multiple ? [] : null;
442
+ emit("clear");
443
+ };
444
+
445
+ const isOptionSelected = (option) => {
446
+ return selectedOptions.value.some((opt) => opt.id === option.id);
447
+ };
448
+
449
+ // Keyboard navigation
450
+ const handleKeydown = (event) => {
451
+ if (props.disabled || props.loading) return;
452
+
453
+ switch (event.key) {
454
+ case "Enter":
455
+ case " ":
456
+ event.preventDefault();
457
+ if (!isOpen.value) {
458
+ toggleSelect();
459
+ } else if (
460
+ focusedIndex.value >= 0 &&
461
+ filteredOptions.value[focusedIndex.value]
462
+ ) {
463
+ selectOption(filteredOptions.value[focusedIndex.value]);
464
+ }
465
+ break;
466
+ case "Escape":
467
+ isOpen.value = false;
468
+ break;
469
+ case "ArrowDown":
470
+ event.preventDefault();
471
+ if (!isOpen.value) {
472
+ toggleSelect();
473
+ } else {
474
+ focusedIndex.value = Math.min(
475
+ focusedIndex.value + 1,
476
+ filteredOptions.value.length - 1
477
+ );
478
+ }
479
+ break;
480
+ case "ArrowUp":
481
+ event.preventDefault();
482
+ if (isOpen.value) {
483
+ focusedIndex.value = Math.max(focusedIndex.value - 1, -1);
484
+ }
485
+ break;
486
+ }
487
+ };
488
+
489
+ // Click outside to close
490
+ onMounted(() => {
491
+ document.addEventListener("click", (event) => {
492
+ if (selectRef.value && !selectRef.value.contains(event.target)) {
493
+ isOpen.value = false;
494
+ }
495
+ });
496
+ });
497
+ </script>
498
+
499
+ <template>
500
+ <div class="space-y-1.5">
501
+ <!-- Label -->
502
+ <label v-if="label" class="block text-sm font-medium text-slate-700 dark:text-slate-300">
503
+ {{ label }}
504
+ </label>
505
+
506
+ <div class="relative" ref="selectRef">
507
+ <!-- Hidden input for form integration -->
508
+ <input v-if="name" type="hidden" :name="name" :value="multiple
509
+ ? Array.isArray(modelValue)
510
+ ? modelValue.join(',')
511
+ : ''
512
+ : modelValue || ''
513
+ " />
514
+
515
+ <!-- Select button/trigger -->
516
+ <button type="button" :class="selectClasses" @click="toggleSelect" @keydown="handleKeydown"
517
+ :disabled="disabled || loading" :aria-expanded="isOpen" :aria-haspopup="true" :name="name">
518
+ <!-- Loading spinner -->
519
+ <div v-if="loading" class="flex items-center">
520
+ <div class="animate-spin rounded-full h-4 w-4 border-b-2 border-current mr-2"></div>
521
+ <span>Cargando...</span>
522
+ </div>
523
+
524
+ <!-- Selected values display -->
525
+ <div v-else class="flex items-center justify-between w-full">
526
+ <!-- Tags mode for multiple selection -->
527
+ <div v-if="multiple && tagsMode && selectedOptions.length" class="flex flex-wrap items-center gap-1 flex-1">
528
+ <div v-for="option in selectedOptions" :key="option.id"
529
+ class="flex flex-nowrap items-center relative z-10 bg-white border border-gray-200 rounded-full p-1 m-1 dark:bg-gray-900 dark:border-gray-700">
530
+ <!-- Avatar/Icon -->
531
+ <div v-if="option.avatar || option.icon" class="size-6 me-1">
532
+ <img v-if="option.avatar" :src="option.avatar" :alt="getOptionLabel(option)"
533
+ class="inline-block rounded-full size-6" />
534
+ <div v-else-if="option.icon" v-html="option.icon" class="size-6"></div>
535
+ </div>
536
+
537
+ <!-- Label -->
538
+ <div class="whitespace-nowrap text-gray-800 dark:text-gray-200 text-sm pl-1.5">
539
+ <slot name="tag" :option="option">
540
+ {{ getOptionLabel(option) }}
541
+ </slot>
542
+ </div>
543
+
544
+ <!-- Remove button -->
545
+ <button type="button" v-if="!disabled" @click.stop="removeTag(option)"
546
+ class="inline-flex shrink-0 justify-center items-center size-5 ms-2 rounded-full text-gray-800 bg-gray-200 hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-400 text-sm dark:bg-gray-700/50 dark:hover:bg-gray-700 dark:text-gray-400 cursor-pointer">
547
+ <svg class="shrink-0 size-3" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
548
+ fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
549
+ <path d="M18 6 6 18" />
550
+ <path d="m6 6 12 12" />
551
+ </svg>
552
+ </button>
553
+ </div>
554
+ <span v-if="!selectedOptions.length" class="text-gray-500 dark:text-gray-400 py-2.5 px-2">
555
+ {{ placeholder }}
556
+ </span>
557
+ </div>
558
+
559
+ <span v-else class="truncate flex-1 text-left pr-10"
560
+ :class="{ 'text-gray-400 dark:text-slate-500': !selectedOptions.length, 'text-slate-900 dark:text-white': selectedOptions.length }">
561
+ <slot name="display" :selectedOptions="selectedOptions" :displayText="displayText">
562
+ {{ displayText }}
563
+ </slot>
564
+ </span>
565
+
566
+ <!-- Clear button -->
567
+ <button type="button" v-if="clearable && selectedOptions.length && !disabled && !loading"
568
+ @click.stop="clearSelection"
569
+ class="absolute end-8 top-1/2 -translate-y-1/2 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-full p-1 transition-colors focus:outline-none focus:ring-1 focus:ring-slate-400">
570
+ <svg class="size-3.5 text-slate-400" fill="currentColor" viewBox="0 0 20 20">
571
+ <path fill-rule="evenodd"
572
+ d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
573
+ clip-rule="evenodd"></path>
574
+ </svg>
575
+ </button>
576
+
577
+ <!-- Dropdown arrow -->
578
+ <div class="absolute top-1/2 end-3 -translate-y-1/2">
579
+ <svg class="shrink-0 size-3.5 text-gray-500 dark:text-gray-500 transition-transform"
580
+ :class="{ 'rotate-180': isOpen }" xmlns="http://www.w3.org/2000/svg" width="24" height="24"
581
+ viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
582
+ stroke-linejoin="round">
583
+ <path d="m7 15 5 5 5-5" />
584
+ <path d="m7 9 5-5 5 5" />
585
+ </svg>
586
+ </div>
587
+ </div>
588
+ </button>
589
+
590
+ <!-- Dropdown menu -->
591
+ <Transition enter-active-class="transition ease-out duration-100" enter-from-class="transform opacity-0 scale-95"
592
+ enter-to-class="transform opacity-100 scale-100" leave-active-class="transition ease-in duration-75"
593
+ leave-from-class="transform opacity-100 scale-100" leave-to-class="transform opacity-0 scale-95">
594
+ <div v-show="isOpen" ref="optionsListRef" @scroll="handleScroll"
595
+ class="absolute z-50 w-full mt-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl shadow-xl max-h-60 overflow-auto">
596
+ <!-- Search input -->
597
+ <div v-if="searchable" class="p-2 border-b border-slate-100 dark:border-slate-700/50 relative">
598
+ <input type="text" :value="searchQuery" :placeholder="searchPlaceholder" @input="handleSearchInput"
599
+ @keydown.enter.prevent
600
+ class="w-full px-3 py-2.5 pr-8 border border-gray-200 dark:border-slate-700 rounded-lg focus:outline-none focus:ring-0 focus:border-gray-400 dark:bg-slate-700 dark:text-white text-sm transition-colors" />
601
+ <button type="button" v-if="searchQuery" @click.prevent="clearSearch"
602
+ class="absolute top-1/2 right-4 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
603
+ <svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
604
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
605
+ </svg>
606
+ </button>
607
+ </div>
608
+
609
+ <!-- Initial Loader if list is empty -->
610
+ <div v-if="isFetching && filteredOptions.length === 0" class="px-3 py-6 text-center text-slate-500">
611
+ <div class="animate-spin rounded-full h-5 w-5 border-b-2 border-current mx-auto mb-2"></div>
612
+ <span class="text-sm">Buscando...</span>
613
+ </div>
614
+
615
+ <!-- Empty option -->
616
+ <button type="button" v-if="allowEmpty && !multiple && !isFetching && filteredOptions.length" @click="
617
+ selectOption({ id: '__empty__', value: null, label: 'Ninguno' })
618
+ " class="w-full px-3 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm">
619
+ <slot name="empty-option">
620
+ <span class="text-gray-500 dark:text-gray-400">Ninguno</span>
621
+ </slot>
622
+ </button>
623
+
624
+ <!-- Options list -->
625
+ <div v-if="filteredOptions.length">
626
+ <button type="button" v-for="(option, index) in filteredOptions" :key="option.id"
627
+ @click="selectOption(option)" :class="[
628
+ 'w-full px-3 py-2 text-left hover:bg-slate-50 dark:hover:bg-slate-700 flex items-center text-sm',
629
+ {
630
+ 'bg-slate-50 dark:bg-slate-700/50': isOptionSelected(option),
631
+ 'opacity-50 cursor-not-allowed': option.disabled,
632
+ 'bg-slate-100 dark:bg-slate-700': focusedIndex === index,
633
+ },
634
+ ]" :disabled="option.disabled">
635
+ <!-- Multiple selection checkbox -->
636
+ <div v-if="multiple" class="mr-2">
637
+ <div :class="[
638
+ 'w-4 h-4 rounded-lg border-2 flex items-center justify-center',
639
+ isOptionSelected(option)
640
+ ? 'bg-slate-600 border-slate-600 text-white'
641
+ : 'border-slate-300 dark:border-slate-600',
642
+ ]">
643
+ <svg v-if="isOptionSelected(option)" class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
644
+ <path fill-rule="evenodd"
645
+ d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
646
+ clip-rule="evenodd"></path>
647
+ </svg>
648
+ </div>
649
+ </div>
650
+
651
+ <!-- Option content -->
652
+ <div class="flex items-center flex-1">
653
+ <!-- Avatar/Icon -->
654
+ <div v-if="option.avatar || option.icon" class="mr-3">
655
+ <img v-if="option.avatar" :src="option.avatar" :alt="option.label" class="w-6 h-6 rounded-full" />
656
+ <component v-else-if="option.icon" :is="option.icon" class="w-5 h-5 text-gray-500" />
657
+ </div>
658
+
659
+ <!-- Option text -->
660
+ <div class="flex-1">
661
+ <slot name="option" :option="option" :selected="isOptionSelected(option)">
662
+ <div>
663
+ <div class="font-bold text-slate-800 dark:text-slate-200">
664
+ {{ getOptionLabel(option) }}
665
+ </div>
666
+ <div v-if="getOptionDescription(option)"
667
+ class="text-[10px] text-slate-400 uppercase tracking-tight">
668
+ {{ getOptionDescription(option) }}
669
+ </div>
670
+ </div>
671
+ </slot>
672
+ </div>
673
+
674
+ <!-- Selection indicator for single mode -->
675
+ <div v-if="!multiple && isOptionSelected(option)" class="ml-2">
676
+ <svg class="w-4 h-4 text-slate-500" fill="currentColor" viewBox="0 0 20 20">
677
+ <path fill-rule="evenodd"
678
+ d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
679
+ clip-rule="evenodd"></path>
680
+ </svg>
681
+ </div>
682
+ </div>
683
+ </button>
684
+ </div>
685
+
686
+ <!-- Infinite Scroll Mini Loader -->
687
+ <div v-if="isFetching && filteredOptions.length > 0" class="py-3 text-center border-t border-slate-100 dark:border-slate-700/50">
688
+ <div class="animate-spin rounded-full h-4 w-4 border-b-2 border-slate-400 mx-auto"></div>
689
+ </div>
690
+
691
+ <!-- End of list marker -->
692
+ <div v-else-if="!hasMorePages && filteredOptions.length > 0" class="py-2 text-center text-[10px] text-slate-400 uppercase tracking-widest border-t border-slate-100 dark:border-slate-700/50">
693
+ No hay más opciones
694
+ </div>
695
+
696
+ <!-- No options message -->
697
+ <div v-else-if="!isFetching && searchQuery && filteredOptions.length === 0"
698
+ class="px-3 py-4 text-center text-gray-500 dark:text-gray-400 text-sm">
699
+ <slot name="no-options"> No se encontraron opciones </slot>
700
+ </div>
701
+
702
+ <!-- No data message -->
703
+ <div v-else-if="!serverOptions.length && !isFetching" class="px-3 py-8 text-center">
704
+ <div class="flex flex-col items-center gap-y-2">
705
+ <div class="size-10 rounded-full bg-slate-50 dark:bg-slate-900/50 flex items-center justify-center">
706
+ <svg class="size-5 text-slate-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
707
+ stroke-width="1.5" stroke="currentColor">
708
+ <path stroke-linecap="round" stroke-linejoin="round"
709
+ d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
710
+ </svg>
711
+ </div>
712
+ <p class="text-sm font-bold text-slate-400">No hay datos disponibles</p>
713
+ <p class="text-[10px] text-slate-300 uppercase font-black tracking-widest">Intenta refrescar la página</p>
714
+ </div>
715
+ </div>
716
+ </div>
717
+ </Transition>
718
+
719
+ <!-- Validation messages -->
720
+ </div>
721
+
722
+ <!-- Error / Hint -->
723
+ <p v-if="error" class="text-xs text-red-500 dark:text-red-400">{{ error }}</p>
724
+ <p v-else-if="hint" class="text-xs text-slate-400 dark:text-slate-500">{{ hint }}</p>
725
+ </div>
726
+ </template>