@innertia-solutions/ui 0.1.0
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/App/EmptyState.vue +433 -0
- package/components/App/Icon.vue +99 -0
- package/components/App/LoadingState.vue +40 -0
- package/components/App/SwitchColorTheme.vue +55 -0
- package/components/Forms/Select.vue +89 -0
- package/components/Modal/DeleteConfirm.vue +163 -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/Notification.vue +45 -0
- package/components/Toast/Process.vue +88 -0
- package/composables/useDate.js +240 -0
- package/composables/useDevice.js +21 -0
- package/composables/useDownload.js +67 -0
- package/composables/useForm.js +259 -0
- package/composables/useRutFormatter.js +20 -0
- package/composables/useTable.ts +124 -0
- package/composables/useTimeAgo.js +25 -0
- package/composables/useToast.js +69 -0
- package/nuxt.config.ts +8 -0
- package/package.json +22 -0
- package/stores/toast.js +131 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, watch, nextTick } from "vue";
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
options: { value: string; label: string }[];
|
|
6
|
+
}>();
|
|
7
|
+
|
|
8
|
+
const modelValue = defineModel<string | null>({ default: null });
|
|
9
|
+
|
|
10
|
+
const selectRef = ref<HTMLSelectElement | null>(null);
|
|
11
|
+
|
|
12
|
+
const reinitHsSelect = async () => {
|
|
13
|
+
await nextTick();
|
|
14
|
+
|
|
15
|
+
const el = selectRef.value;
|
|
16
|
+
if (!el) return;
|
|
17
|
+
|
|
18
|
+
const instance = window.HSSelect?.getInstance?.(el);
|
|
19
|
+
if (instance?.destroy) instance.destroy();
|
|
20
|
+
|
|
21
|
+
new window.HSSelect(el);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
watch(
|
|
25
|
+
() => props.options,
|
|
26
|
+
async () => {
|
|
27
|
+
await reinitHsSelect();
|
|
28
|
+
},
|
|
29
|
+
{ deep: true }
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
onMounted(() => {
|
|
33
|
+
reinitHsSelect();
|
|
34
|
+
});
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<template>
|
|
38
|
+
<ClientOnly>
|
|
39
|
+
<!-- Fallback mientras se monta en el cliente -->
|
|
40
|
+
<template #fallback>
|
|
41
|
+
<div
|
|
42
|
+
class="h-12 bg-slate-100 dark:bg-slate-800 animate-pulse rounded-lg"
|
|
43
|
+
></div>
|
|
44
|
+
</template>
|
|
45
|
+
<div class="relative">
|
|
46
|
+
<select
|
|
47
|
+
ref="selectRef"
|
|
48
|
+
class="hs-select w-full"
|
|
49
|
+
:value="modelValue"
|
|
50
|
+
@change="(e) => modelValue?.value ? modelValue.value = (e.target as HTMLSelectElement).value : null"
|
|
51
|
+
data-hs-select='{
|
|
52
|
+
"placeholder": "Select option...",
|
|
53
|
+
"toggleTag": "<button type=\"button\" aria-expanded=\"false\"></button>",
|
|
54
|
+
"toggleClasses": "hs-select-disabled:pointer-events-none hs-select-disabled:opacity-50 relative py-2 ps-4 pe-9 flex gap-x-2 text-nowrap w-full cursor-pointer bg-white border border-slate-200 rounded-lg text-start text-sm focus:outline-hidden focus:ring-2 focus:ring-blue-500 dark:bg-slate-900 dark:border-slate-700 dark:text-slate-400 dark:focus:outline-hidden dark:focus:ring-1 dark:focus:ring-slate-600",
|
|
55
|
+
"dropdownClasses": "mt-1 z-50 w-full max-h-72 p-1 space-y-0.5 bg-white border border-slate-200 rounded-lg overflow-hidden overflow-y-auto dark:bg-slate-900 dark:border-slate-700",
|
|
56
|
+
"optionClasses": "py-2 px-4 w-full text-sm text-slate-800 cursor-pointer hover:bg-slate-100 rounded-lg focus:outline-hidden focus:bg-slate-100 dark:bg-slate-900 dark:hover:bg-slate-800 dark:text-slate-200 dark:focus:bg-slate-800",
|
|
57
|
+
"optionTemplate": "<div class=\"flex justify-between items-center w-full\"><span data-title></span><span class=\"hidden hs-selected:block\"><svg class=\"shrink-0 size-3.5 text-blue-600 dark:text-blue-500 \" xmlns=\"http:.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"20 6 9 17 4 12\"/></svg></span></div>"
|
|
58
|
+
}'
|
|
59
|
+
>
|
|
60
|
+
<option disabled value="">Selecciona</option>
|
|
61
|
+
<option
|
|
62
|
+
v-for="option in options"
|
|
63
|
+
:key="option.value"
|
|
64
|
+
:value="option.value"
|
|
65
|
+
>
|
|
66
|
+
{{ option.label }}
|
|
67
|
+
</option>
|
|
68
|
+
</select>
|
|
69
|
+
|
|
70
|
+
<div class="absolute top-1/2 end-2.5 -translate-y-1/2">
|
|
71
|
+
<svg
|
|
72
|
+
class="shrink-0 size-4 text-slate-500 dark:text-slate-500"
|
|
73
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
74
|
+
width="24"
|
|
75
|
+
height="24"
|
|
76
|
+
viewBox="0 0 24 24"
|
|
77
|
+
fill="none"
|
|
78
|
+
stroke="currentColor"
|
|
79
|
+
stroke-width="2"
|
|
80
|
+
stroke-linecap="round"
|
|
81
|
+
stroke-linejoin="round"
|
|
82
|
+
>
|
|
83
|
+
<path d="m7 15 5 5 5-5"></path>
|
|
84
|
+
<path d="m7 9 5-5 5 5"></path>
|
|
85
|
+
</svg>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</ClientOnly>
|
|
89
|
+
</template>
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Modal
|
|
3
|
+
:model-value="modelValue"
|
|
4
|
+
@update:model-value="$emit('update:modelValue', $event)"
|
|
5
|
+
size="sm"
|
|
6
|
+
:closable="!loading"
|
|
7
|
+
:backdrop-dismiss="!loading"
|
|
8
|
+
:show-header="false"
|
|
9
|
+
:show-footer="false"
|
|
10
|
+
>
|
|
11
|
+
<!-- Close Button -->
|
|
12
|
+
<div class="absolute top-3 right-3">
|
|
13
|
+
<button
|
|
14
|
+
type="button"
|
|
15
|
+
:disabled="loading"
|
|
16
|
+
class="size-8 shrink-0 flex justify-center items-center gap-x-2 rounded-full border border-transparent bg-gray-100 text-gray-800 hover:bg-gray-200 disabled:opacity-50 disabled:pointer-events-none focus:outline-hidden focus:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 dark:text-gray-400 dark:focus:bg-gray-600"
|
|
17
|
+
aria-label="Close"
|
|
18
|
+
@click="closeModal"
|
|
19
|
+
>
|
|
20
|
+
<span class="sr-only">Cerrar</span>
|
|
21
|
+
<svg
|
|
22
|
+
class="shrink-0 size-4"
|
|
23
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
24
|
+
width="24"
|
|
25
|
+
height="24"
|
|
26
|
+
viewBox="0 0 24 24"
|
|
27
|
+
fill="none"
|
|
28
|
+
stroke="currentColor"
|
|
29
|
+
stroke-width="2"
|
|
30
|
+
stroke-linecap="round"
|
|
31
|
+
stroke-linejoin="round"
|
|
32
|
+
>
|
|
33
|
+
<path d="M18 6 6 18" />
|
|
34
|
+
<path d="m6 6 12 12" />
|
|
35
|
+
</svg>
|
|
36
|
+
</button>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<!-- Body -->
|
|
40
|
+
<div class="p-5 sm:p-10">
|
|
41
|
+
<h3 class="text-lg font-medium text-gray-800 dark:text-gray-200">
|
|
42
|
+
{{ title || "Confirmar eliminación" }}
|
|
43
|
+
</h3>
|
|
44
|
+
<p class="mt-2 text-sm text-gray-500 dark:text-gray-500">
|
|
45
|
+
{{
|
|
46
|
+
message ||
|
|
47
|
+
"Esta acción es irreversible. ¿Estás seguro de que deseas continuar?"
|
|
48
|
+
}}
|
|
49
|
+
</p>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<!-- Footer -->
|
|
53
|
+
<div class="pb-5 px-5 sm:px-10 flex justify-center items-center gap-x-3">
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
:disabled="loading"
|
|
57
|
+
class="py-2.5 px-3 w-full inline-flex justify-center items-center gap-x-1.5 text-sm font-medium rounded-xl border border-gray-200 bg-white text-gray-800 shadow-2xs hover:bg-gray-50 disabled:opacity-50 disabled:pointer-events-none focus:outline-hidden focus:bg-gray-50 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-700 dark:focus:bg-gray-700"
|
|
58
|
+
@click="closeModal"
|
|
59
|
+
>
|
|
60
|
+
{{ cancelText || "Cancelar" }}
|
|
61
|
+
</button>
|
|
62
|
+
<button
|
|
63
|
+
type="button"
|
|
64
|
+
:class="[
|
|
65
|
+
'py-2.5 px-3 w-full inline-flex justify-center items-center gap-x-1.5 text-sm font-medium rounded-xl border border-transparent text-white disabled:opacity-50 disabled:pointer-events-none focus:outline-hidden',
|
|
66
|
+
dangerMode
|
|
67
|
+
? 'bg-red-500 hover:bg-red-600 focus:bg-red-600'
|
|
68
|
+
: 'bg-purple-500 hover:bg-purple-600 focus:bg-purple-600',
|
|
69
|
+
]"
|
|
70
|
+
:disabled="loading"
|
|
71
|
+
@click="handleConfirm"
|
|
72
|
+
>
|
|
73
|
+
<svg
|
|
74
|
+
v-if="loading"
|
|
75
|
+
class="w-4 h-4 animate-spin mr-1"
|
|
76
|
+
fill="none"
|
|
77
|
+
stroke="currentColor"
|
|
78
|
+
viewBox="0 0 24 24"
|
|
79
|
+
>
|
|
80
|
+
<path
|
|
81
|
+
stroke-linecap="round"
|
|
82
|
+
stroke-linejoin="round"
|
|
83
|
+
stroke-width="2"
|
|
84
|
+
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
85
|
+
></path>
|
|
86
|
+
</svg>
|
|
87
|
+
{{ loading ? loadingText : confirmText || "Confirmar" }}
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
</Modal>
|
|
91
|
+
</template>
|
|
92
|
+
|
|
93
|
+
<script setup>
|
|
94
|
+
import { ref } from "vue";
|
|
95
|
+
|
|
96
|
+
// Props
|
|
97
|
+
const props = defineProps({
|
|
98
|
+
modelValue: {
|
|
99
|
+
type: Boolean,
|
|
100
|
+
default: false,
|
|
101
|
+
},
|
|
102
|
+
title: {
|
|
103
|
+
type: String,
|
|
104
|
+
default: "",
|
|
105
|
+
},
|
|
106
|
+
message: {
|
|
107
|
+
type: String,
|
|
108
|
+
default: "",
|
|
109
|
+
},
|
|
110
|
+
confirmText: {
|
|
111
|
+
type: String,
|
|
112
|
+
default: "",
|
|
113
|
+
},
|
|
114
|
+
cancelText: {
|
|
115
|
+
type: String,
|
|
116
|
+
default: "",
|
|
117
|
+
},
|
|
118
|
+
loadingText: {
|
|
119
|
+
type: String,
|
|
120
|
+
default: "Procesando...",
|
|
121
|
+
},
|
|
122
|
+
dangerMode: {
|
|
123
|
+
type: Boolean,
|
|
124
|
+
default: true,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Emits
|
|
129
|
+
const emit = defineEmits(["update:modelValue", "confirm", "cancel"]);
|
|
130
|
+
|
|
131
|
+
// State
|
|
132
|
+
const loading = ref(false);
|
|
133
|
+
|
|
134
|
+
// Exponer loading para que el padre pueda controlarlo
|
|
135
|
+
defineExpose({
|
|
136
|
+
loading,
|
|
137
|
+
setLoading: (value) => { loading.value = value },
|
|
138
|
+
closeModal: () => {
|
|
139
|
+
loading.value = false;
|
|
140
|
+
emit("update:modelValue", false);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Methods
|
|
145
|
+
const closeModal = () => {
|
|
146
|
+
if (!loading.value) {
|
|
147
|
+
emit("update:modelValue", false);
|
|
148
|
+
emit("cancel");
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const handleConfirm = () => {
|
|
153
|
+
if (loading.value) return; // Prevenir doble click
|
|
154
|
+
|
|
155
|
+
loading.value = true;
|
|
156
|
+
|
|
157
|
+
// Emitir el evento confirm
|
|
158
|
+
emit("confirm");
|
|
159
|
+
|
|
160
|
+
// El loading se mantiene activo
|
|
161
|
+
// Para desactivarlo, el padre debe llamar a una función o cerrar el modal
|
|
162
|
+
};
|
|
163
|
+
</script>
|
|
@@ -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>
|