@innertia-solutions/innertia-nuxt 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/.github/workflows/auto-publish.yml +64 -0
  2. package/.github/workflows/release.yml +59 -0
  3. package/README.md +60 -0
  4. package/app.config.ts +70 -0
  5. package/components/Admin/Base.vue +144 -0
  6. package/components/Admin/Header.vue +32 -0
  7. package/components/Admin/Page.vue +65 -0
  8. package/components/Admin/PageHeader.vue +31 -0
  9. package/components/App/Button.vue +59 -0
  10. package/components/App/DevEnvironmentBar.vue +43 -0
  11. package/components/App/Dropdown.vue +286 -0
  12. package/components/App/EmptyState.vue +433 -0
  13. package/components/App/LoadingState.vue +40 -0
  14. package/components/App/PageLoadingSpinner.vue +118 -0
  15. package/components/App/PreviewDock.vue +64 -0
  16. package/components/App/SwitchColorTheme.vue +51 -0
  17. package/components/App/Tag.vue +193 -0
  18. package/components/DataTable.vue +713 -0
  19. package/components/Forms/DatePicker.vue +255 -0
  20. package/components/Forms/Input.vue +75 -0
  21. package/components/Forms/Select.vue +100 -0
  22. package/components/Forms/SelectServer.vue +726 -0
  23. package/components/Layout/Admin.vue +32 -0
  24. package/components/Layout/Auth.vue +29 -0
  25. package/components/Layout/SidebarWithAppColumn.vue +388 -0
  26. package/components/Layout/TopBar.vue +113 -0
  27. package/components/MobileBlocker.vue +85 -0
  28. package/components/MobileLoginPicker.vue +83 -0
  29. package/components/Modal/Base.vue +29 -0
  30. package/components/Modal/DeleteConfirm.vue +48 -0
  31. package/components/Modal.vue +103 -0
  32. package/components/Nav/Tabs.vue +55 -0
  33. package/components/PermissionsTree.vue +272 -0
  34. package/components/Table/Database.vue +183 -0
  35. package/components/Table/DownloadDropdown.vue +111 -0
  36. package/components/Table/Enterprise.vue +540 -0
  37. package/components/Table/FilterDropdown.vue +226 -0
  38. package/components/Table/Grid.vue +62 -0
  39. package/components/Table/Kanban.vue +188 -0
  40. package/components/Table/List.vue +128 -0
  41. package/components/Table/PreviewTimeline.vue +118 -0
  42. package/components/Table/Standard.vue +1217 -0
  43. package/components/Table/index.vue +974 -0
  44. package/components/TableExportable.vue +172 -0
  45. package/components/TableFilter.vue +93 -0
  46. package/components/Toast/Alert.vue +113 -0
  47. package/components/Toast/Container.vue +34 -0
  48. package/components/Toast/Notification.vue +45 -0
  49. package/components/Toast/Process.vue +88 -0
  50. package/composables/useApi.js +95 -0
  51. package/composables/useApp.ts +46 -0
  52. package/composables/useAuth.js +82 -0
  53. package/composables/useContext.js +44 -0
  54. package/composables/useDate.js +241 -0
  55. package/composables/useDevice.js +21 -0
  56. package/composables/useDockedPreviews.js +56 -0
  57. package/composables/useDownload.js +87 -0
  58. package/composables/useEntity.js +82 -0
  59. package/composables/useForm.js +119 -0
  60. package/composables/useInnertiaMode.ts +25 -0
  61. package/composables/useMobileGuard.ts +81 -0
  62. package/composables/useNotifications.js +22 -0
  63. package/composables/usePermissions.js +23 -0
  64. package/composables/useRealtime.js +123 -0
  65. package/composables/useRequestInterceptors.js +27 -0
  66. package/composables/useRoles.js +53 -0
  67. package/composables/useRutFormatter.js +39 -0
  68. package/composables/useTable.ts +94 -0
  69. package/composables/useTablePreferences.ts +33 -0
  70. package/composables/useTenant.js +27 -0
  71. package/composables/useTimeAgo.js +37 -0
  72. package/composables/useToast.js +69 -0
  73. package/composables/useUserRealtime.js +17 -0
  74. package/composables/useUsers.js +111 -0
  75. package/css/themes/autumn.css +401 -0
  76. package/css/themes/bubblegum.css +408 -0
  77. package/css/themes/cashmere.css +412 -0
  78. package/css/themes/harvest.css +416 -0
  79. package/css/themes/moon.css +140 -0
  80. package/css/themes/ocean.css +273 -0
  81. package/css/themes/olive.css +413 -0
  82. package/css/themes/retro.css +431 -0
  83. package/css/themes/theme.css +725 -0
  84. package/error.vue +78 -0
  85. package/middleware/01.detect-subdomain.global.ts +43 -0
  86. package/middleware/02.validate-tenant.global.ts +67 -0
  87. package/middleware/03.apps.global.ts +88 -0
  88. package/middleware/auth.ts +9 -0
  89. package/middleware/guest.ts +9 -0
  90. package/nuxt.config.ts +42 -0
  91. package/package.json +60 -0
  92. package/pages/tenant-error.vue +50 -0
  93. package/plugins/api-auth.ts +12 -0
  94. package/plugins/api-tenant.client.ts +21 -0
  95. package/plugins/appearance.ts +8 -0
  96. package/plugins/auth-init.ts +34 -0
  97. package/plugins/dark-state.client.ts +29 -0
  98. package/plugins/dockedPreviewsSync.client.js +17 -0
  99. package/plugins/preline.client.ts +68 -0
  100. package/plugins/theme.client.ts +7 -0
  101. package/plugins/vue-query.ts +29 -0
  102. package/public/init-theme.js +15 -0
  103. package/spark.css +721 -0
  104. package/stores/auth.js +130 -0
  105. package/stores/dockedPreviews.js +34 -0
  106. package/stores/notifications.js +24 -0
  107. package/stores/tenant.js +54 -0
  108. package/stores/toast.js +129 -0
@@ -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-card-line";
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-card transition-colors cursor-pointer text-foreground";
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-foreground">
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-card border border-card-line rounded-full p-1 m-1">
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-foreground 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-foreground bg-surface-1 hover:bg-surface-1 focus:outline-none focus:ring-2 focus:ring-gray-400 text-sm 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-muted-foreground 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-muted-foreground': !selectedOptions.length, 'text-foreground': 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-muted-hover 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-muted-foreground 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-dropdown border border-dropdown-line rounded-xl shadow-xl max-h-60 overflow-auto">
596
+ <!-- Search input -->
597
+ <div v-if="searchable" class="p-2 border-b border-card-line 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-card-line rounded-lg focus:outline-none focus:ring-0 focus:border-gray-400 dark:bg-surface 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-muted-foreground">
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-muted-hover text-sm">
619
+ <slot name="empty-option">
620
+ <span class="text-muted-foreground">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-muted-hover flex items-center text-sm',
629
+ {
630
+ 'bg-muted': isOptionSelected(option),
631
+ 'opacity-50 cursor-not-allowed': option.disabled,
632
+ 'bg-surface': 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-card-line',
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-foreground">
664
+ {{ getOptionLabel(option) }}
665
+ </div>
666
+ <div v-if="getOptionDescription(option)"
667
+ class="text-[10px] text-muted-foreground 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-card-line">
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-muted-foreground uppercase tracking-widest border-t border-card-line">
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-muted-foreground 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-muted flex items-center justify-center">
706
+ <svg class="size-5 text-muted-foreground-2" 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-muted-foreground">No hay datos disponibles</p>
713
+ <p class="text-[10px] text-muted-foreground-2 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-muted-foreground">{{ hint }}</p>
725
+ </div>
726
+ </template>