@innertia-solutions/nuxt-theme-spark 0.1.11

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 (34) hide show
  1. package/components/Admin/Base.vue +73 -0
  2. package/components/Admin/Header.vue +32 -0
  3. package/components/Admin/Page.vue +5 -0
  4. package/components/Admin/PageHeader.vue +18 -0
  5. package/components/App/Button.vue +59 -0
  6. package/components/App/Dropdown.vue +286 -0
  7. package/components/App/EmptyState.vue +433 -0
  8. package/components/App/LoadingState.vue +40 -0
  9. package/components/App/PageLoadingSpinner.vue +118 -0
  10. package/components/App/SwitchColorTheme.vue +55 -0
  11. package/components/App/Tag.vue +193 -0
  12. package/components/Forms/DatePicker.vue +255 -0
  13. package/components/Forms/Input.vue +72 -0
  14. package/components/Forms/Select.vue +100 -0
  15. package/components/Forms/SelectServer.vue +726 -0
  16. package/components/Layout/Admin.vue +32 -0
  17. package/components/Layout/Auth.vue +29 -0
  18. package/components/Modal/DeleteConfirm.vue +48 -0
  19. package/components/Modal.vue +103 -0
  20. package/components/Nav/Tabs.vue +39 -0
  21. package/components/Table/DownloadDropdown.vue +111 -0
  22. package/components/Table/FilterDropdown.vue +226 -0
  23. package/components/Toast/Alert.vue +113 -0
  24. package/components/Toast/Container.vue +34 -0
  25. package/components/Toast/Notification.vue +45 -0
  26. package/components/Toast/Process.vue +88 -0
  27. package/nuxt.config.ts +15 -0
  28. package/package.json +45 -0
  29. package/plugins/preline.client.ts +68 -0
  30. package/shared/composables/useForm.js +119 -0
  31. package/shared/composables/useTable.ts +84 -0
  32. package/shared/composables/useToast.js +69 -0
  33. package/shared/stores/toast.js +129 -0
  34. package/spark.css +207 -0
@@ -0,0 +1,32 @@
1
+ <script setup>
2
+ const showAnimation = ref(false)
3
+ onMounted(() => {
4
+ const seen = sessionStorage.getItem('auth-entered')
5
+ if (!seen) {
6
+ showAnimation.value = true
7
+ sessionStorage.setItem('auth-entered', 'true')
8
+ }
9
+ })
10
+ </script>
11
+
12
+ <template>
13
+ <div :class="{ 'animate-entrance': showAnimation }">
14
+ <AdminBase>
15
+ <template #logo><slot name="logo" /></template>
16
+ <template #menu><slot name="menu" /></template>
17
+ <template #user-footer><slot name="user-footer" /></template>
18
+
19
+ <AdminPage>
20
+ <slot />
21
+ </AdminPage>
22
+ </AdminBase>
23
+ </div>
24
+ </template>
25
+
26
+ <style scoped>
27
+ .animate-entrance { animation: fadeInScale 0.5s cubic-bezier(0.4, 0, 0.2, 1); }
28
+ @keyframes fadeInScale {
29
+ from { opacity: 0; transform: scale(0.96); filter: blur(4px); }
30
+ to { opacity: 1; transform: scale(1); filter: blur(0); }
31
+ }
32
+ </style>
@@ -0,0 +1,29 @@
1
+ <template>
2
+ <div class="bg-slate-50 dark:bg-slate-900">
3
+ <div class="absolute top-4 right-4 z-20">
4
+ <slot name="theme-switch" />
5
+ </div>
6
+ <main class="flex min-h-full">
7
+ <!-- Columna izquierda: imagen/bienvenida -->
8
+ <div class="relative hidden min-h-screen lg:w-200 xl:w-[65%] bg-slate-100 lg:flex flex-col justify-between p-6 dark:bg-slate-950 overflow-hidden">
9
+ <slot name="background" />
10
+ <div class="z-0 p-20 pt-[max(180px,10vh)]">
11
+ <slot name="welcome" />
12
+ </div>
13
+ </div>
14
+
15
+ <!-- Columna derecha: formulario -->
16
+ <div class="grow px-12 z-10 -ml-6 pr-6 bg-white dark:bg-slate-900 rounded-tl-[1.5rem] rounded-bl-[1.5rem]">
17
+ <div class="h-full min-h-screen sm:w-112 flex flex-col justify-center mx-auto space-y-5 p-4">
18
+ <div class="flex justify-center mb-6">
19
+ <slot name="logo" />
20
+ </div>
21
+ <slot />
22
+ <div class="absolute bottom-4 right-4 text-xs text-slate-400">
23
+ <slot name="version" />
24
+ </div>
25
+ </div>
26
+ </div>
27
+ </main>
28
+ </div>
29
+ </template>
@@ -0,0 +1,48 @@
1
+ <script setup>
2
+ const props = defineProps({
3
+ modelValue: { type: Boolean, default: false },
4
+ title: { type: String, default: 'Confirmar eliminación' },
5
+ message: { type: String, default: 'Esta acción es irreversible. ¿Estás seguro de que deseas continuar?' },
6
+ confirmText: { type: String, default: 'Eliminar' },
7
+ cancelText: { type: String, default: 'Cancelar' },
8
+ loading: { type: Boolean, default: false },
9
+ })
10
+
11
+ const emit = defineEmits(['update:modelValue', 'confirm', 'cancel'])
12
+
13
+ const close = () => {
14
+ if (props.loading) return
15
+ emit('update:modelValue', false)
16
+ emit('cancel')
17
+ }
18
+ </script>
19
+
20
+ <template>
21
+ <Modal
22
+ :model-value="modelValue"
23
+ :title="title"
24
+ size="sm"
25
+ :closable="!loading"
26
+ :backdrop-dismiss="!loading"
27
+ @update:model-value="$emit('update:modelValue', $event)"
28
+ >
29
+ <p class="text-sm text-slate-500 dark:text-slate-400">{{ message }}</p>
30
+
31
+ <div class="flex justify-end gap-2 mt-5">
32
+ <AppButton
33
+ :text="cancelText"
34
+ severity="secondary"
35
+ size="sm"
36
+ :disabled="loading"
37
+ @click="close"
38
+ />
39
+ <AppButton
40
+ :text="confirmText"
41
+ severity="danger"
42
+ size="sm"
43
+ :loading="loading"
44
+ @click="$emit('confirm')"
45
+ />
46
+ </div>
47
+ </Modal>
48
+ </template>
@@ -0,0 +1,103 @@
1
+ <script setup>
2
+ const props = defineProps({
3
+ modelValue: { type: Boolean, default: false },
4
+ title: { type: String, default: '' },
5
+ size: { type: String, default: 'md', validator: v => ['xs','sm','md','lg','xl','2xl','3xl','fullscreen'].includes(v) },
6
+ closable: { type: Boolean, default: true },
7
+ backdropDismiss: { type: Boolean, default: true },
8
+ showHeader: { type: Boolean, default: true },
9
+ showFooter: { type: Boolean, default: false },
10
+ })
11
+
12
+ const emit = defineEmits(['update:modelValue', 'close'])
13
+
14
+ const modalId = `modal-${Math.random().toString(36).slice(2, 9)}`
15
+
16
+ const sizeClass = computed(() => ({
17
+ xs: 'max-w-xs', sm: 'max-w-sm', md: 'max-w-md',
18
+ lg: 'max-w-lg', xl: 'max-w-xl', '2xl': 'max-w-2xl',
19
+ '3xl': 'max-w-3xl', fullscreen: 'max-w-full',
20
+ }[props.size] ?? 'max-w-md'))
21
+
22
+ const close = () => {
23
+ emit('update:modelValue', false)
24
+ emit('close')
25
+ }
26
+
27
+ const onBackdrop = (e) => {
28
+ if (e.target === e.currentTarget && props.backdropDismiss && props.closable) close()
29
+ }
30
+
31
+ const onEsc = (e) => { if (e.key === 'Escape' && props.modelValue && props.closable) close() }
32
+
33
+ watch(() => props.modelValue, v => {
34
+ document.body.style.overflow = v ? 'hidden' : ''
35
+ })
36
+
37
+ onMounted(() => document.addEventListener('keydown', onEsc))
38
+ onUnmounted(() => {
39
+ document.removeEventListener('keydown', onEsc)
40
+ document.body.style.overflow = ''
41
+ })
42
+ </script>
43
+
44
+ <template>
45
+ <Teleport v-if="modelValue" to="body">
46
+ <Transition name="modal" appear>
47
+ <div
48
+ class="fixed inset-0 z-[9999] bg-black/40 dark:bg-black/60 backdrop-blur-sm flex items-center justify-center p-4"
49
+ role="dialog"
50
+ tabindex="-1"
51
+ :aria-labelledby="`${modalId}-label`"
52
+ @click="onBackdrop"
53
+ >
54
+ <div
55
+ :class="['bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl shadow-xl w-full modal-content', sizeClass]"
56
+ @click.stop
57
+ >
58
+ <!-- Header -->
59
+ <div v-if="showHeader" class="flex items-center justify-between px-5 py-4 border-b border-slate-200 dark:border-slate-700">
60
+ <h3 :id="`${modalId}-label`" class="text-sm font-semibold text-slate-800 dark:text-slate-100">
61
+ <slot name="header">{{ title }}</slot>
62
+ </h3>
63
+ <button
64
+ v-if="closable"
65
+ type="button"
66
+ class="size-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 dark:hover:text-slate-200 dark:hover:bg-slate-700 transition-colors"
67
+ @click="close"
68
+ >
69
+ <svg class="size-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
70
+ <path d="M18 6 6 18M6 6l12 12" stroke-linecap="round" stroke-linejoin="round" />
71
+ </svg>
72
+ </button>
73
+ </div>
74
+
75
+ <!-- Body -->
76
+ <div class="p-5">
77
+ <slot />
78
+ </div>
79
+
80
+ <!-- Footer -->
81
+ <div v-if="showFooter" class="flex items-center justify-end gap-2 px-5 py-4 border-t border-slate-200 dark:border-slate-700">
82
+ <slot name="footer">
83
+ <AppButton v-if="closable" text="Cerrar" severity="secondary" size="sm" @click="close" />
84
+ </slot>
85
+ </div>
86
+ </div>
87
+ </div>
88
+ </Transition>
89
+ </Teleport>
90
+ </template>
91
+
92
+ <style scoped>
93
+ .modal-enter-active,
94
+ .modal-leave-active { transition: opacity 0.15s ease; }
95
+ .modal-enter-from,
96
+ .modal-leave-to { opacity: 0; }
97
+
98
+ .modal-enter-from .modal-content,
99
+ .modal-leave-to .modal-content { transform: scale(0.97) translateY(-8px); opacity: 0; }
100
+ .modal-enter-to .modal-content,
101
+ .modal-leave-from .modal-content { transform: scale(1) translateY(0); opacity: 1; }
102
+ .modal-content { transition: transform 0.15s ease, opacity 0.15s ease; }
103
+ </style>
@@ -0,0 +1,39 @@
1
+ <script setup lang="ts">
2
+ import { useRoute } from 'vue-router'
3
+
4
+ interface Tab {
5
+ label: string
6
+ to: string
7
+ icon?: any
8
+ exact?: boolean
9
+ }
10
+
11
+ const props = withDefaults(defineProps<{
12
+ tabs: Tab[]
13
+ activeClass?: string
14
+ }>(), {
15
+ activeClass: 'bg-white dark:bg-slate-800 text-blue-600 dark:text-blue-400 shadow-sm',
16
+ })
17
+
18
+ const route = useRoute()
19
+
20
+ const isActive = (tab: Tab) =>
21
+ tab.exact ? route.path === tab.to : route.path.startsWith(tab.to)
22
+ </script>
23
+
24
+ <template>
25
+ <div class="flex items-center gap-x-1 p-1 bg-slate-100 dark:bg-slate-900/50 rounded-xl w-fit border border-slate-200 dark:border-slate-700">
26
+ <NuxtLink
27
+ v-for="tab in tabs"
28
+ :key="tab.to"
29
+ :to="tab.to"
30
+ class="flex items-center gap-x-2 px-4 py-2 text-xs font-bold rounded-lg transition-all"
31
+ :class="isActive(tab)
32
+ ? activeClass
33
+ : 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200'"
34
+ >
35
+ <component :is="tab.icon" v-if="tab.icon" class="size-4" />
36
+ {{ tab.label }}
37
+ </NuxtLink>
38
+ </div>
39
+ </template>
@@ -0,0 +1,111 @@
1
+ <script setup>
2
+ import {
3
+ IconFileTypeXls,
4
+ IconCodeDots,
5
+ IconFileTypePdf,
6
+ IconFileTypeCsv,
7
+ IconDownload,
8
+ } from "@tabler/icons-vue";
9
+
10
+ const props = defineProps({
11
+ tableRef: { type: Object, required: true },
12
+ });
13
+
14
+ const exportAllPages = ref(true);
15
+ const exportFilteredRows = ref(true);
16
+
17
+ const exportTable = (format) => {
18
+ if (props.tableRef) {
19
+ props.tableRef.exportTable(
20
+ format,
21
+ exportAllPages.value,
22
+ exportFilteredRows.value
23
+ );
24
+ }
25
+ };
26
+ </script>
27
+ <template>
28
+ <div
29
+ class="hs-dropdown [--auto-close:inside] [--placement:bottom-right] relative inline-block"
30
+ >
31
+ <button
32
+ id="hs-as-table-table-export-dropdown"
33
+ type="button"
34
+ class="py-1.5 sm:py-2 px-2.5 inline-flex items-center gap-x-1.5 text-sm sm:text-xs font-medium rounded-lg border border-slate-200 bg-white text-slate-800 shadow-2xs hover:bg-slate-50 disabled:opacity-50 disabled:pointer-events-none focus:outline-hidden focus:bg-slate-50 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700 dark:focus:bg-slate-700"
35
+ aria-haspopup="menu"
36
+ aria-expanded="false"
37
+ aria-label="Dropdown"
38
+ >
39
+ <IconDownload class="shrink-0 size-4" stroke="1.5" />
40
+ Exportar
41
+ </button>
42
+ <div
43
+ class="hs-dropdown-menu transition-[opacity,margin] duration hs-dropdown-open:opacity-100 opacity-0 hidden divide-y divide-slate-200 min-w-48 z-10 bg-white shadow-md rounded-lg p-2 mt-2 dark:divide-slate-700 dark:bg-slate-800 dark:border dark:border-slate-700 border-t border-slate-200 dark:border-slate-700"
44
+ role="menu"
45
+ aria-orientation="vertical"
46
+ aria-labelledby="hs-as-table-table-export-dropdown"
47
+ >
48
+ <div class="py-2 first:pt-0 last:pb-0">
49
+ <a
50
+ class="flex items-center gap-x-3 py-2 px-3 rounded-lg text-sm text-slate-800 hover:bg-slate-100 focus:outline-hidden focus:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-300 dark:focus:bg-slate-700 dark:focus:text-slate-300"
51
+ href="#"
52
+ @click="exportTable('xlsx')"
53
+ >
54
+ <IconFileTypeXls class="shrink-0 size-5" stroke="1.5" />
55
+ Excel
56
+ </a>
57
+ <a
58
+ class="flex items-center gap-x-3 py-2 px-3 rounded-lg text-sm text-slate-800 hover:bg-slate-100 focus:outline-hidden focus:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-300 dark:focus:bg-slate-700 dark:focus:text-slate-300"
59
+ href="#"
60
+ @click="exportTable('csv')"
61
+ >
62
+ <IconFileTypeCsv class="shrink-0 size-5" stroke="1.5" />
63
+ CSV
64
+ </a>
65
+ <a
66
+ class="flex items-center gap-x-3 py-2 px-3 rounded-lg text-sm text-slate-800 hover:bg-slate-100 focus:outline-hidden focus:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-300 dark:focus:bg-slate-700 dark:focus:text-slate-300"
67
+ href="#"
68
+ @click="exportTable('pdf')"
69
+ >
70
+ <IconFileTypePdf class="shrink-0 size-5" stroke="1.5" />
71
+ PDF
72
+ </a>
73
+ <a
74
+ class="flex items-center gap-x-3 py-2 px-3 rounded-lg text-sm text-slate-800 hover:bg-slate-100 focus:outline-hidden focus:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-300 dark:focus:bg-slate-700 dark:focus:text-slate-300"
75
+ href="#"
76
+ @click="exportTable('json')"
77
+ >
78
+ <IconCodeDots class="shrink-0 size-5" stroke="1.5" />
79
+ JSON
80
+ </a>
81
+ </div>
82
+ <!-- checkbox - todas las paginas -->
83
+ <div
84
+ class="flex flex-col gap-y-2 py-2 px-3"
85
+ data-hs-dropdown-ignore-click
86
+ >
87
+ <label
88
+ class="inline-flex items-center gap-2 text-sm text-slate-700 dark:text-slate-200"
89
+ >
90
+ <input
91
+ type="checkbox"
92
+ v-model="exportAllPages"
93
+ class="shrink-0 border-slate-300 rounded-sm text-blue-600 focus:ring-blue-500 dark:bg-slate-800 dark:border-slate-600"
94
+ />
95
+ Todas las páginas
96
+ </label>
97
+
98
+ <label
99
+ class="inline-flex items-center gap-2 text-sm text-slate-700 dark:text-slate-200"
100
+ >
101
+ <input
102
+ type="checkbox"
103
+ v-model="exportFilteredRows"
104
+ class="shrink-0 border-slate-300 rounded-sm text-blue-600 focus:ring-blue-500 dark:bg-slate-800 dark:border-slate-600"
105
+ />
106
+ Solo filas filtradas
107
+ </label>
108
+ </div>
109
+ </div>
110
+ </div>
111
+ </template>
@@ -0,0 +1,226 @@
1
+ <script setup>
2
+ const props = defineProps({
3
+ modelValue: {
4
+ type: [String, Number],
5
+ default: null,
6
+ },
7
+ options: {
8
+ type: Array,
9
+ default: () => [],
10
+ },
11
+ placeholder: {
12
+ type: String,
13
+ default: "Seleccionar...",
14
+ },
15
+ optionLabelKey: {
16
+ type: String,
17
+ default: "label",
18
+ },
19
+ optionValueKey: {
20
+ type: String,
21
+ default: "value",
22
+ },
23
+ clearable: {
24
+ type: Boolean,
25
+ default: true,
26
+ },
27
+ disabled: {
28
+ type: Boolean,
29
+ default: false,
30
+ },
31
+ menuClass: {
32
+ type: String,
33
+ default: "",
34
+ },
35
+ });
36
+
37
+ const emit = defineEmits(["update:modelValue", "change"]);
38
+
39
+ const isOpen = ref(false);
40
+ const dropdownRef = ref(null);
41
+
42
+ const getOptionLabel = (option) => option?.[props.optionLabelKey] ?? option?.label ?? "";
43
+ const getOptionValue = (option) => option?.[props.optionValueKey] ?? option?.value ?? null;
44
+
45
+ const hasSelection = computed(() => props.modelValue !== null && props.modelValue !== undefined && props.modelValue !== "");
46
+
47
+ const selectedOption = computed(() =>
48
+ props.options.find((option) => getOptionValue(option) === props.modelValue),
49
+ );
50
+
51
+ const displayText = computed(() => {
52
+ if (!selectedOption.value) return props.placeholder;
53
+ return getOptionLabel(selectedOption.value) || props.placeholder;
54
+ });
55
+
56
+ const selectClasses = computed(() => {
57
+ const base =
58
+ "relative w-full rounded-lg border bg-white dark:bg-slate-800 transition-colors cursor-pointer text-slate-900 dark:text-white py-2 px-3 text-sm focus:outline-none focus:ring-0 focus:border-gray-400";
59
+ const validation = "border-gray-200 dark:border-slate-700";
60
+ const disabled = props.disabled ? "opacity-50 cursor-not-allowed" : "";
61
+ return `${base} ${validation} ${disabled}`;
62
+ });
63
+
64
+ const closeDropdown = () => {
65
+ isOpen.value = false;
66
+ };
67
+
68
+ const toggleDropdown = () => {
69
+ if (props.disabled) return;
70
+ isOpen.value = !isOpen.value;
71
+ };
72
+
73
+ const selectOption = (option) => {
74
+ const selectedValue = getOptionValue(option);
75
+ const nextValue = props.modelValue === selectedValue ? null : selectedValue;
76
+ emit("update:modelValue", nextValue);
77
+ emit("change", nextValue);
78
+ closeDropdown();
79
+ };
80
+
81
+ const clearSelection = () => {
82
+ emit("update:modelValue", null);
83
+ emit("change", null);
84
+ closeDropdown();
85
+ };
86
+
87
+ const isOptionSelected = (option) => getOptionValue(option) === props.modelValue;
88
+
89
+ const handleClickOutside = (event) => {
90
+ if (dropdownRef.value && !dropdownRef.value.contains(event.target)) {
91
+ closeDropdown();
92
+ }
93
+ };
94
+
95
+ onMounted(() => document.addEventListener("mousedown", handleClickOutside));
96
+ onUnmounted(() => document.removeEventListener("mousedown", handleClickOutside));
97
+ </script>
98
+
99
+ <template>
100
+ <div ref="dropdownRef" class="relative w-full">
101
+ <button
102
+ type="button"
103
+ :class="selectClasses"
104
+ :disabled="disabled"
105
+ :aria-expanded="isOpen"
106
+ :aria-haspopup="true"
107
+ @click="toggleDropdown"
108
+ >
109
+ <div class="flex items-center justify-between w-full">
110
+ <span
111
+ class="truncate flex-1 text-left pr-10"
112
+ :class="{
113
+ 'text-gray-400 dark:text-slate-500': !hasSelection,
114
+ 'text-slate-900 dark:text-white': hasSelection,
115
+ }"
116
+ >
117
+ <slot name="display" :selected-option="selectedOption" :display-text="displayText">
118
+ {{ displayText }}
119
+ </slot>
120
+ </span>
121
+
122
+ <span
123
+ v-if="clearable && hasSelection && !disabled"
124
+ 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"
125
+ role="button"
126
+ tabindex="0"
127
+ @click.stop="clearSelection"
128
+ @keydown.enter.prevent="clearSelection"
129
+ @keydown.space.prevent="clearSelection"
130
+ >
131
+ <svg class="size-3.5 text-slate-400" fill="currentColor" viewBox="0 0 20 20">
132
+ <path
133
+ fill-rule="evenodd"
134
+ 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"
135
+ clip-rule="evenodd"
136
+ />
137
+ </svg>
138
+ </span>
139
+
140
+ <div class="absolute top-1/2 end-3 -translate-y-1/2">
141
+ <svg
142
+ class="shrink-0 size-3.5 text-gray-500 dark:text-gray-500 transition-transform"
143
+ :class="{ 'rotate-180': isOpen }"
144
+ xmlns="http://www.w3.org/2000/svg"
145
+ width="24"
146
+ height="24"
147
+ viewBox="0 0 24 24"
148
+ fill="none"
149
+ stroke="currentColor"
150
+ stroke-width="2"
151
+ stroke-linecap="round"
152
+ stroke-linejoin="round"
153
+ >
154
+ <path d="m7 15 5 5 5-5" />
155
+ <path d="m7 9 5-5 5 5" />
156
+ </svg>
157
+ </div>
158
+ </div>
159
+ </button>
160
+
161
+ <Transition
162
+ enter-active-class="transition ease-out duration-100"
163
+ enter-from-class="transform opacity-0 scale-95"
164
+ enter-to-class="transform opacity-100 scale-100"
165
+ leave-active-class="transition ease-in duration-75"
166
+ leave-from-class="transform opacity-100 scale-100"
167
+ leave-to-class="transform opacity-0 scale-95"
168
+ >
169
+ <div
170
+ v-show="isOpen"
171
+ :class="[
172
+ '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',
173
+ menuClass,
174
+ ]"
175
+ >
176
+ <div v-if="options.length">
177
+ <button
178
+ v-for="option in options"
179
+ :key="getOptionValue(option)"
180
+ type="button"
181
+ :class="[
182
+ 'w-full px-3 py-2 text-left hover:bg-slate-50 dark:hover:bg-slate-700 flex items-center text-sm',
183
+ isOptionSelected(option) ? 'bg-slate-50 dark:bg-slate-700/50' : '',
184
+ ]"
185
+ @click="selectOption(option)"
186
+ >
187
+ <slot name="option" :option="option" :selected="isOptionSelected(option)">
188
+ <div class="flex items-center flex-1 min-w-0">
189
+ <span
190
+ v-if="option.dot"
191
+ class="relative flex-shrink-0 size-2.5 flex items-center justify-center mr-2.5"
192
+ >
193
+ <span
194
+ v-if="option.pulse"
195
+ :class="['animate-ping absolute inline-flex h-full w-full rounded-full opacity-75', option.dot]"
196
+ />
197
+ <span :class="['relative inline-flex rounded-full size-2.5', option.dot]" />
198
+ </span>
199
+ <span class="font-bold text-slate-800 dark:text-slate-200 truncate">
200
+ {{ getOptionLabel(option) }}
201
+ </span>
202
+ </div>
203
+ </slot>
204
+
205
+ <svg
206
+ v-if="isOptionSelected(option)"
207
+ class="w-4 h-4 text-slate-500 ml-2 shrink-0"
208
+ fill="currentColor"
209
+ viewBox="0 0 20 20"
210
+ >
211
+ <path
212
+ fill-rule="evenodd"
213
+ 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"
214
+ clip-rule="evenodd"
215
+ />
216
+ </svg>
217
+ </button>
218
+ </div>
219
+
220
+ <div v-else class="px-3 py-4 text-center text-gray-500 dark:text-gray-400 text-sm">
221
+ Sin opciones
222
+ </div>
223
+ </div>
224
+ </Transition>
225
+ </div>
226
+ </template>