@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.
- package/.github/workflows/auto-publish.yml +64 -0
- package/.github/workflows/release.yml +59 -0
- package/README.md +60 -0
- package/app.config.ts +70 -0
- package/components/Admin/Base.vue +144 -0
- package/components/Admin/Header.vue +32 -0
- package/components/Admin/Page.vue +65 -0
- package/components/Admin/PageHeader.vue +31 -0
- package/components/App/Button.vue +59 -0
- package/components/App/DevEnvironmentBar.vue +43 -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/PreviewDock.vue +64 -0
- package/components/App/SwitchColorTheme.vue +51 -0
- package/components/App/Tag.vue +193 -0
- package/components/DataTable.vue +713 -0
- package/components/Forms/DatePicker.vue +255 -0
- package/components/Forms/Input.vue +75 -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/Layout/SidebarWithAppColumn.vue +388 -0
- package/components/Layout/TopBar.vue +113 -0
- package/components/MobileBlocker.vue +85 -0
- package/components/MobileLoginPicker.vue +83 -0
- package/components/Modal/Base.vue +29 -0
- package/components/Modal/DeleteConfirm.vue +48 -0
- package/components/Modal.vue +103 -0
- package/components/Nav/Tabs.vue +55 -0
- package/components/PermissionsTree.vue +272 -0
- package/components/Table/Database.vue +183 -0
- package/components/Table/DownloadDropdown.vue +111 -0
- package/components/Table/Enterprise.vue +540 -0
- package/components/Table/FilterDropdown.vue +226 -0
- package/components/Table/Grid.vue +62 -0
- package/components/Table/Kanban.vue +188 -0
- package/components/Table/List.vue +128 -0
- package/components/Table/PreviewTimeline.vue +118 -0
- package/components/Table/Standard.vue +1217 -0
- package/components/Table/index.vue +974 -0
- package/components/TableExportable.vue +172 -0
- package/components/TableFilter.vue +93 -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/composables/useApi.js +95 -0
- package/composables/useApp.ts +46 -0
- package/composables/useAuth.js +82 -0
- package/composables/useContext.js +44 -0
- package/composables/useDate.js +241 -0
- package/composables/useDevice.js +21 -0
- package/composables/useDockedPreviews.js +56 -0
- package/composables/useDownload.js +87 -0
- package/composables/useEntity.js +82 -0
- package/composables/useForm.js +119 -0
- package/composables/useInnertiaMode.ts +25 -0
- package/composables/useMobileGuard.ts +81 -0
- package/composables/useNotifications.js +22 -0
- package/composables/usePermissions.js +23 -0
- package/composables/useRealtime.js +123 -0
- package/composables/useRequestInterceptors.js +27 -0
- package/composables/useRoles.js +53 -0
- package/composables/useRutFormatter.js +39 -0
- package/composables/useTable.ts +94 -0
- package/composables/useTablePreferences.ts +33 -0
- package/composables/useTenant.js +27 -0
- package/composables/useTimeAgo.js +37 -0
- package/composables/useToast.js +69 -0
- package/composables/useUserRealtime.js +17 -0
- package/composables/useUsers.js +111 -0
- package/css/themes/autumn.css +401 -0
- package/css/themes/bubblegum.css +408 -0
- package/css/themes/cashmere.css +412 -0
- package/css/themes/harvest.css +416 -0
- package/css/themes/moon.css +140 -0
- package/css/themes/ocean.css +273 -0
- package/css/themes/olive.css +413 -0
- package/css/themes/retro.css +431 -0
- package/css/themes/theme.css +725 -0
- package/error.vue +78 -0
- package/middleware/01.detect-subdomain.global.ts +43 -0
- package/middleware/02.validate-tenant.global.ts +67 -0
- package/middleware/03.apps.global.ts +88 -0
- package/middleware/auth.ts +9 -0
- package/middleware/guest.ts +9 -0
- package/nuxt.config.ts +42 -0
- package/package.json +60 -0
- package/pages/tenant-error.vue +50 -0
- package/plugins/api-auth.ts +12 -0
- package/plugins/api-tenant.client.ts +21 -0
- package/plugins/appearance.ts +8 -0
- package/plugins/auth-init.ts +34 -0
- package/plugins/dark-state.client.ts +29 -0
- package/plugins/dockedPreviewsSync.client.js +17 -0
- package/plugins/preline.client.ts +68 -0
- package/plugins/theme.client.ts +7 -0
- package/plugins/vue-query.ts +29 -0
- package/public/init-theme.js +15 -0
- package/spark.css +721 -0
- package/stores/auth.js +130 -0
- package/stores/dockedPreviews.js +34 -0
- package/stores/notifications.js +24 -0
- package/stores/tenant.js +54 -0
- 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>
|