@innertia-solutions/ui 0.1.3 → 0.1.5

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