@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.
- package/components/Admin/Base.vue +73 -0
- package/components/Admin/Header.vue +32 -0
- package/components/Admin/Page.vue +5 -0
- package/components/Admin/PageHeader.vue +18 -0
- package/components/App/Button.vue +59 -0
- package/components/App/Dropdown.vue +286 -0
- package/components/App/EmptyState.vue +433 -0
- package/components/App/LoadingState.vue +40 -0
- package/components/App/PageLoadingSpinner.vue +118 -0
- package/components/App/SwitchColorTheme.vue +55 -0
- package/components/App/Tag.vue +193 -0
- package/components/Forms/DatePicker.vue +255 -0
- package/components/Forms/Input.vue +72 -0
- package/components/Forms/Select.vue +100 -0
- package/components/Forms/SelectServer.vue +726 -0
- package/components/Layout/Admin.vue +32 -0
- package/components/Layout/Auth.vue +29 -0
- package/components/Modal/DeleteConfirm.vue +48 -0
- package/components/Modal.vue +103 -0
- package/components/Nav/Tabs.vue +39 -0
- package/components/Table/DownloadDropdown.vue +111 -0
- package/components/Table/FilterDropdown.vue +226 -0
- package/components/Toast/Alert.vue +113 -0
- package/components/Toast/Container.vue +34 -0
- package/components/Toast/Notification.vue +45 -0
- package/components/Toast/Process.vue +88 -0
- package/nuxt.config.ts +15 -0
- package/package.json +45 -0
- package/plugins/preline.client.ts +68 -0
- package/shared/composables/useForm.js +119 -0
- package/shared/composables/useTable.ts +84 -0
- package/shared/composables/useToast.js +69 -0
- package/shared/stores/toast.js +129 -0
- 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>
|