@innertia-solutions/innertia-nuxt 0.1.43 → 0.1.44
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/Forms/Select.vue +28 -23
- package/components/Table/MasterDetail.vue +169 -0
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
const props = defineProps<{
|
|
2
|
+
const props = withDefaults(defineProps<{
|
|
3
3
|
options: { value: string | number; label: string; disabled?: boolean }[]
|
|
4
4
|
modelValue?: string | number | null
|
|
5
5
|
label?: string
|
|
@@ -11,7 +11,11 @@ const props = defineProps<{
|
|
|
11
11
|
loading?: boolean
|
|
12
12
|
/** Falló la carga de opciones: muestra mensaje + botón Reintentar (emite 'retry'). */
|
|
13
13
|
loadError?: boolean | string
|
|
14
|
-
|
|
14
|
+
/** Permite volver a "Seleccionar…" (blanquear la selección). Default true. */
|
|
15
|
+
clearable?: boolean
|
|
16
|
+
}>(), {
|
|
17
|
+
clearable: true,
|
|
18
|
+
})
|
|
15
19
|
|
|
16
20
|
const emit = defineEmits<{
|
|
17
21
|
'update:modelValue': [value: string | number | null]
|
|
@@ -23,14 +27,29 @@ const loadErrorMessage = computed(() =>
|
|
|
23
27
|
typeof props.loadError === 'string' && props.loadError ? props.loadError : 'No se pudieron cargar las opciones.'
|
|
24
28
|
)
|
|
25
29
|
|
|
30
|
+
const emptyLabel = computed(() => props.placeholder ?? 'Seleccionar...')
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Config de HSSelect. Si `clearable`, NO seteamos `placeholder` en Preline: así la
|
|
34
|
+
* opción vacía "Seleccionar…" queda como una opción normal (re-seleccionable) y el
|
|
35
|
+
* usuario puede blanquear la selección. Si no, la vacía actúa de placeholder (Preline
|
|
36
|
+
* la oculta una vez elegido un valor).
|
|
37
|
+
*/
|
|
38
|
+
const hsConfig = computed(() => JSON.stringify({
|
|
39
|
+
...(props.clearable ? {} : { placeholder: emptyLabel.value }),
|
|
40
|
+
dropdownScope: 'window',
|
|
41
|
+
toggleTag: '<button type="button" aria-expanded="false"></button>',
|
|
42
|
+
toggleClasses: 'innertia-field hs-select-disabled:pointer-events-none hs-select-disabled:opacity-50 relative pe-9 flex gap-x-2 items-center text-nowrap cursor-pointer text-start',
|
|
43
|
+
dropdownClasses: 'z-[10000] w-full max-h-72 py-1 bg-[color:var(--field-dropdown-bg)] border border-[color:var(--field-border)] rounded-control overflow-hidden overflow-y-auto shadow-lg',
|
|
44
|
+
optionClasses: 'py-2 px-3 w-full text-sm text-foreground cursor-pointer bg-transparent hover:bg-muted-hover focus:outline-hidden focus:bg-muted-hover',
|
|
45
|
+
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://www.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>',
|
|
46
|
+
}))
|
|
47
|
+
|
|
26
48
|
const selectRef = ref<HTMLSelectElement | null>(null)
|
|
27
49
|
|
|
28
50
|
/**
|
|
29
|
-
* Init Preline HSSelect una sola vez después del mount.
|
|
30
|
-
*
|
|
31
|
-
* un re-mount limpio, el consumer puede cambiar el `:key` del componente.
|
|
32
|
-
*
|
|
33
|
-
* Si Preline aún no está disponible (plugin carga async), esperamos.
|
|
51
|
+
* Init Preline HSSelect una sola vez después del mount. Para forzar un re-mount
|
|
52
|
+
* limpio (ej. cambian las options), el consumer cambia el `:key` del componente.
|
|
34
53
|
*/
|
|
35
54
|
const initHsSelect = async () => {
|
|
36
55
|
await nextTick()
|
|
@@ -41,15 +60,11 @@ const initHsSelect = async () => {
|
|
|
41
60
|
const HSSelect = (window as any).HSSelect
|
|
42
61
|
if (!HSSelect) {
|
|
43
62
|
if (attempts < 20) {
|
|
44
|
-
// Re-intentar hasta 20 frames (~330ms) esperando que cargue el plugin
|
|
45
63
|
requestAnimationFrame(() => tryInit(attempts + 1))
|
|
46
64
|
}
|
|
47
65
|
return
|
|
48
66
|
}
|
|
49
|
-
|
|
50
|
-
// Evitar doble init si ya hay una instancia registrada
|
|
51
67
|
if (HSSelect.getInstance?.(el)) return
|
|
52
|
-
|
|
53
68
|
try {
|
|
54
69
|
new HSSelect(el)
|
|
55
70
|
} catch (e) {
|
|
@@ -108,18 +123,10 @@ const handleChange = (e: Event) => {
|
|
|
108
123
|
class="hs-select w-full"
|
|
109
124
|
:value="modelValue ?? ''"
|
|
110
125
|
:disabled="disabled"
|
|
126
|
+
:data-hs-select="hsConfig"
|
|
111
127
|
@change="handleChange"
|
|
112
|
-
data-hs-select='{
|
|
113
|
-
"placeholder": "Seleccionar...",
|
|
114
|
-
"dropdownScope": "window",
|
|
115
|
-
"toggleTag": "<button type=\"button\" aria-expanded=\"false\"></button>",
|
|
116
|
-
"toggleClasses": "innertia-field hs-select-disabled:pointer-events-none hs-select-disabled:opacity-50 relative pe-9 flex gap-x-2 items-center text-nowrap cursor-pointer text-start",
|
|
117
|
-
"dropdownClasses": "z-[10000] w-full max-h-72 py-1 bg-[color:var(--field-dropdown-bg)] border border-[color:var(--field-border)] rounded-control overflow-hidden overflow-y-auto shadow-lg",
|
|
118
|
-
"optionClasses": "py-2 px-3 w-full text-sm text-foreground cursor-pointer bg-transparent hover:bg-muted-hover focus:outline-hidden focus:bg-muted-hover",
|
|
119
|
-
"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://www.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>"
|
|
120
|
-
}'
|
|
121
128
|
>
|
|
122
|
-
<option value="">{{
|
|
129
|
+
<option value="">{{ emptyLabel }}</option>
|
|
123
130
|
<option
|
|
124
131
|
v-for="option in options"
|
|
125
132
|
:key="option.value"
|
|
@@ -145,7 +152,5 @@ const handleChange = (e: Event) => {
|
|
|
145
152
|
* Con `dropdownScope:'window'` Preline teletransporta el panel del dropdown al
|
|
146
153
|
* <body> y lo posiciona con floating-ui (fixed + offset). Así escapa el overflow
|
|
147
154
|
* de cualquier contenedor con scroll (modales, paneles, etc.) y flota por encima.
|
|
148
|
-
* El trigger conserva sus bordes/esquinas completos porque el panel ya no se
|
|
149
|
-
* "fusiona" con él — flota separado.
|
|
150
155
|
*/
|
|
151
156
|
</style>
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { IconSearch, IconLoader2 } from '@tabler/icons-vue'
|
|
3
|
+
import { useInfiniteQuery } from '@tanstack/vue-query'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Table.MasterDetail — lista buscable con scroll infinito (izquierda) + panel de
|
|
7
|
+
* detalle/preview (derecha) con header, acciones, skeleton y contenido. Calca el
|
|
8
|
+
* layout CRM del design system.
|
|
9
|
+
*
|
|
10
|
+
* La lista consume el MISMO endpoint DataTable del recurso (GET con {search,page,perPage,
|
|
11
|
+
* ...params}) → {data, meta{current_page,last_page}}. Sin endpoints /list dedicados: el
|
|
12
|
+
* DataTable::render del backend ya pagina/filtra/exporta.
|
|
13
|
+
* Slots: #action (header lista), #item="{row,active}", #header="{row}", default (detalle), #empty.
|
|
14
|
+
* `loading` (prop) muestra el skeleton del panel de detalle mientras el consumer carga.
|
|
15
|
+
* `selectedId` controla la selección desde fuera (deep-link por URL); si no se pasa,
|
|
16
|
+
* el componente mantiene su propia selección interna (modo no-controlado).
|
|
17
|
+
*/
|
|
18
|
+
const props = defineProps({
|
|
19
|
+
endpoint: { type: String, required: true },
|
|
20
|
+
name: { type: String, required: true },
|
|
21
|
+
params: { type: Object, default: () => ({}) },
|
|
22
|
+
title: { type: String, default: '' },
|
|
23
|
+
searchPlaceholder: { type: String, default: 'Buscar…' },
|
|
24
|
+
perPage: { type: Number, default: 20 },
|
|
25
|
+
itemTitle: { type: String, default: 'name' },
|
|
26
|
+
itemSubtitle: { type: String, default: 'email' },
|
|
27
|
+
loading: { type: Boolean, default: false },
|
|
28
|
+
emptyText: { type: String, default: 'Selecciona un elemento para ver su detalle.' },
|
|
29
|
+
selectedId: { type: [String, Number], default: null },
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const emit = defineEmits(['select'])
|
|
33
|
+
|
|
34
|
+
const api = useApi()
|
|
35
|
+
const search = ref('')
|
|
36
|
+
const sentinel = ref(null)
|
|
37
|
+
const innerSelected = ref(null)
|
|
38
|
+
|
|
39
|
+
// Modo controlado (selectedId) o no-controlado (click interno). Comparamos como
|
|
40
|
+
// string para tolerar ids uuid vs numéricos.
|
|
41
|
+
const activeId = computed(() => props.selectedId ?? innerSelected.value?.id ?? null)
|
|
42
|
+
const isActive = (row) => activeId.value != null && String(row?.id) === String(activeId.value)
|
|
43
|
+
const hasSelection = computed(() => activeId.value != null)
|
|
44
|
+
|
|
45
|
+
let searchTimeout = null
|
|
46
|
+
const debouncedSearch = ref('')
|
|
47
|
+
watch(search, (v) => {
|
|
48
|
+
if (searchTimeout) clearTimeout(searchTimeout)
|
|
49
|
+
searchTimeout = setTimeout(() => { debouncedSearch.value = v }, 400)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const queryKey = computed(() => [props.name, 'md', debouncedSearch.value, props.params])
|
|
53
|
+
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery({
|
|
54
|
+
queryKey,
|
|
55
|
+
queryFn: ({ pageParam = 1 }) => api.get(props.endpoint, {
|
|
56
|
+
params: { search: debouncedSearch.value, page: pageParam, perPage: props.perPage, ...props.params },
|
|
57
|
+
}),
|
|
58
|
+
getNextPageParam: (last) => {
|
|
59
|
+
const meta = last?.meta ?? last
|
|
60
|
+
if (!meta?.current_page || !meta?.last_page) return undefined
|
|
61
|
+
return meta.current_page < meta.last_page ? meta.current_page + 1 : undefined
|
|
62
|
+
},
|
|
63
|
+
initialPageParam: 1,
|
|
64
|
+
})
|
|
65
|
+
const rows = computed(() => data.value?.pages.flatMap(p => p?.data ?? (Array.isArray(p) ? p : [])) ?? [])
|
|
66
|
+
|
|
67
|
+
let observer = null
|
|
68
|
+
onMounted(() => {
|
|
69
|
+
observer = new IntersectionObserver((entries) => {
|
|
70
|
+
if (entries[0].isIntersecting && hasNextPage.value && !isFetchingNextPage.value) fetchNextPage()
|
|
71
|
+
}, { rootMargin: '200px' })
|
|
72
|
+
if (sentinel.value) observer.observe(sentinel.value)
|
|
73
|
+
})
|
|
74
|
+
onBeforeUnmount(() => observer?.disconnect())
|
|
75
|
+
watch(sentinel, (el) => { if (el && observer) observer.observe(el) })
|
|
76
|
+
|
|
77
|
+
const initial = (row) => String(row?.[props.itemTitle] ?? '?').trim().charAt(0).toUpperCase()
|
|
78
|
+
|
|
79
|
+
function pick(row) {
|
|
80
|
+
innerSelected.value = row
|
|
81
|
+
emit('select', row)
|
|
82
|
+
}
|
|
83
|
+
</script>
|
|
84
|
+
|
|
85
|
+
<template>
|
|
86
|
+
<div class="flex rounded-card border border-card-line overflow-hidden bg-card h-[calc(100dvh-13rem)] min-h-[520px]">
|
|
87
|
+
<!-- ── Lista ── -->
|
|
88
|
+
<aside class="w-80 shrink-0 border-e border-card-line flex flex-col">
|
|
89
|
+
<div v-if="title || $slots.action" class="p-3 flex items-center justify-between gap-2 border-b border-card-line">
|
|
90
|
+
<h2 class="text-sm font-semibold text-foreground">{{ title }}</h2>
|
|
91
|
+
<slot name="action" />
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<div class="px-3 pt-3 pb-2">
|
|
95
|
+
<div class="relative">
|
|
96
|
+
<div class="absolute inset-y-0 start-0 flex items-center ps-2.5 pointer-events-none">
|
|
97
|
+
<IconSearch class="size-4 text-muted-foreground" :stroke-width="1.5" />
|
|
98
|
+
</div>
|
|
99
|
+
<input v-model="search" type="text" :placeholder="searchPlaceholder"
|
|
100
|
+
class="py-2 px-8 block w-full bg-surface border-transparent text-foreground placeholder:text-muted-foreground rounded-lg text-sm focus:bg-card focus:border-primary focus:ring-primary/30" />
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<div class="flex-1 overflow-y-auto [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-card-line">
|
|
105
|
+
<!-- Skeleton lista -->
|
|
106
|
+
<div v-if="isLoading" class="p-3 space-y-3">
|
|
107
|
+
<div v-for="i in 8" :key="i" class="flex items-center gap-3 animate-pulse">
|
|
108
|
+
<span class="size-8 rounded-full bg-surface-1 shrink-0" />
|
|
109
|
+
<div class="flex-1 space-y-2"><span class="block h-3 w-2/3 bg-surface-1 rounded" /><span class="block h-2.5 w-1/2 bg-surface-1 rounded" /></div>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<template v-else>
|
|
114
|
+
<div v-for="row in rows" :key="row.id ?? JSON.stringify(row)"
|
|
115
|
+
class="relative cursor-pointer before:absolute before:inset-y-0 before:inset-s-0 before:w-[3px] before:rounded-full before:transition-colors"
|
|
116
|
+
:class="isActive(row) ? 'before:bg-primary bg-surface' : 'before:bg-transparent hover:before:bg-card-line'"
|
|
117
|
+
@click="pick(row)">
|
|
118
|
+
<slot name="item" :row="row" :active="isActive(row)">
|
|
119
|
+
<div class="p-3 flex items-center gap-x-3 border-b border-card-line">
|
|
120
|
+
<span class="shrink-0 size-8 flex items-center justify-center rounded-full bg-primary/10 text-primary text-xs font-semibold uppercase">{{ initial(row) }}</span>
|
|
121
|
+
<div class="grow min-w-0">
|
|
122
|
+
<p class="truncate font-medium text-[13px] text-foreground">{{ row[itemTitle] }}</p>
|
|
123
|
+
<p class="truncate text-xs text-muted-foreground">{{ row[itemSubtitle] }}</p>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</slot>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<div v-if="!rows.length" class="text-center py-12 text-muted-foreground text-sm">Sin resultados</div>
|
|
130
|
+
<div ref="sentinel" class="flex justify-center py-4">
|
|
131
|
+
<IconLoader2 v-if="isFetchingNextPage" class="size-4 animate-spin text-muted-foreground" />
|
|
132
|
+
</div>
|
|
133
|
+
</template>
|
|
134
|
+
</div>
|
|
135
|
+
</aside>
|
|
136
|
+
|
|
137
|
+
<!-- ── Detalle ── -->
|
|
138
|
+
<section class="grow overflow-y-auto">
|
|
139
|
+
<!-- Vacío -->
|
|
140
|
+
<div v-if="!hasSelection" class="h-full flex flex-col items-center justify-center text-center text-muted-foreground px-6">
|
|
141
|
+
<slot name="empty">
|
|
142
|
+
<IconSearch class="size-10 mb-3 opacity-30" :stroke-width="1.5" />
|
|
143
|
+
<p class="text-sm">{{ emptyText }}</p>
|
|
144
|
+
</slot>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<!-- Skeleton detalle -->
|
|
148
|
+
<div v-else-if="loading" class="p-6 animate-pulse">
|
|
149
|
+
<div class="flex items-center gap-4">
|
|
150
|
+
<span class="size-16 rounded-full bg-surface-1 shrink-0" />
|
|
151
|
+
<div class="flex-1 space-y-2"><span class="block h-5 w-1/3 bg-surface-1 rounded" /><span class="block h-3 w-1/4 bg-surface-1 rounded" /></div>
|
|
152
|
+
</div>
|
|
153
|
+
<div class="mt-8 space-y-3">
|
|
154
|
+
<span v-for="i in 4" :key="i" class="block h-4 bg-surface-1 rounded" :style="{ width: (90 - i * 12) + '%' }" />
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<!-- Contenido -->
|
|
159
|
+
<div v-else>
|
|
160
|
+
<div v-if="$slots.header" class="p-6 border-b border-card-line">
|
|
161
|
+
<slot name="header" :id="activeId" />
|
|
162
|
+
</div>
|
|
163
|
+
<div class="p-6">
|
|
164
|
+
<slot :id="activeId" />
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
</section>
|
|
168
|
+
</div>
|
|
169
|
+
</template>
|