@innertia-solutions/innertia-nuxt 0.1.43 → 0.1.45

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.
@@ -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
- * No destruimos ni re-inicializamos en cambios de options — para forzar
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="">{{ placeholder ?? 'Seleccionar...' }}</option>
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,184 @@
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
+ /** Alto del contenedor (CSS). Ajústalo para que la página quepa sin scroll. */
31
+ height: { type: String, default: 'calc(100dvh - 13rem)' },
32
+ /** Auto-refresco por realtime (se suscribe a los meta.channels del backend). */
33
+ realtime: { type: Boolean, default: true },
34
+ })
35
+
36
+ const emit = defineEmits(['select'])
37
+
38
+ const api = useApi()
39
+ const search = ref('')
40
+ const sentinel = ref(null)
41
+ const innerSelected = ref(null)
42
+
43
+ // Modo controlado (selectedId) o no-controlado (click interno). Comparamos como
44
+ // string para tolerar ids uuid vs numéricos.
45
+ const activeId = computed(() => props.selectedId ?? innerSelected.value?.id ?? null)
46
+ const isActive = (row) => activeId.value != null && String(row?.id) === String(activeId.value)
47
+ const hasSelection = computed(() => activeId.value != null)
48
+
49
+ let searchTimeout = null
50
+ const debouncedSearch = ref('')
51
+ watch(search, (v) => {
52
+ if (searchTimeout) clearTimeout(searchTimeout)
53
+ searchTimeout = setTimeout(() => { debouncedSearch.value = v }, 400)
54
+ })
55
+
56
+ const queryKey = computed(() => [props.name, 'md', debouncedSearch.value, props.params])
57
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, refetch } = useInfiniteQuery({
58
+ queryKey,
59
+ queryFn: ({ pageParam = 1 }) => api.get(props.endpoint, {
60
+ params: { search: debouncedSearch.value, page: pageParam, perPage: props.perPage, ...props.params },
61
+ }),
62
+ getNextPageParam: (last) => {
63
+ const meta = last?.meta ?? last
64
+ if (!meta?.current_page || !meta?.last_page) return undefined
65
+ return meta.current_page < meta.last_page ? meta.current_page + 1 : undefined
66
+ },
67
+ initialPageParam: 1,
68
+ })
69
+ const rows = computed(() => data.value?.pages.flatMap(p => p?.data ?? (Array.isArray(p) ? p : [])) ?? [])
70
+
71
+ // Realtime: se suscribe a los canales que anuncia el backend en meta.channels
72
+ // (entity.{tabla}[.{tenant}]) y refetchea la lista al recibir {canal}.changed.
73
+ const rt = props.realtime ? useTableRealtime(() => refetch()) : null
74
+ watch(data, () => {
75
+ if (!rt) return
76
+ const pages = data.value?.pages ?? []
77
+ const meta = pages[pages.length - 1]?.meta ?? pages[0]?.meta
78
+ rt.sync(meta?.channels ?? [])
79
+ }, { immediate: true })
80
+ onBeforeUnmount(() => rt?.teardown())
81
+
82
+ let observer = null
83
+ onMounted(() => {
84
+ observer = new IntersectionObserver((entries) => {
85
+ if (entries[0].isIntersecting && hasNextPage.value && !isFetchingNextPage.value) fetchNextPage()
86
+ }, { rootMargin: '200px' })
87
+ if (sentinel.value) observer.observe(sentinel.value)
88
+ })
89
+ onBeforeUnmount(() => observer?.disconnect())
90
+ watch(sentinel, (el) => { if (el && observer) observer.observe(el) })
91
+
92
+ const initial = (row) => String(row?.[props.itemTitle] ?? '?').trim().charAt(0).toUpperCase()
93
+
94
+ function pick(row) {
95
+ innerSelected.value = row
96
+ emit('select', row)
97
+ }
98
+ </script>
99
+
100
+ <template>
101
+ <div class="flex rounded-card border border-card-line overflow-hidden bg-card min-h-[360px]" :style="{ height }">
102
+ <!-- ── Lista ── -->
103
+ <aside class="w-80 shrink-0 border-e border-card-line flex flex-col">
104
+ <div v-if="title || $slots.action" class="p-3 flex items-center justify-between gap-2 border-b border-card-line">
105
+ <h2 class="text-sm font-semibold text-foreground">{{ title }}</h2>
106
+ <slot name="action" />
107
+ </div>
108
+
109
+ <div class="px-3 pt-3 pb-2">
110
+ <div class="relative">
111
+ <div class="absolute inset-y-0 start-0 flex items-center ps-2.5 pointer-events-none">
112
+ <IconSearch class="size-4 text-muted-foreground" :stroke-width="1.5" />
113
+ </div>
114
+ <input v-model="search" type="text" :placeholder="searchPlaceholder"
115
+ 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" />
116
+ </div>
117
+ </div>
118
+
119
+ <div class="flex-1 overflow-y-auto [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-card-line">
120
+ <!-- Skeleton lista -->
121
+ <div v-if="isLoading" class="p-3 space-y-3">
122
+ <div v-for="i in 8" :key="i" class="flex items-center gap-3 animate-pulse">
123
+ <span class="size-8 rounded-full bg-surface-1 shrink-0" />
124
+ <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>
125
+ </div>
126
+ </div>
127
+
128
+ <template v-else>
129
+ <div v-for="row in rows" :key="row.id ?? JSON.stringify(row)"
130
+ class="relative cursor-pointer before:absolute before:inset-y-0 before:inset-s-0 before:w-[3px] before:rounded-full before:transition-colors"
131
+ :class="isActive(row) ? 'before:bg-primary bg-surface' : 'before:bg-transparent hover:before:bg-card-line'"
132
+ @click="pick(row)">
133
+ <slot name="item" :row="row" :active="isActive(row)">
134
+ <div class="p-3 flex items-center gap-x-3 border-b border-card-line">
135
+ <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>
136
+ <div class="grow min-w-0">
137
+ <p class="truncate font-medium text-[13px] text-foreground">{{ row[itemTitle] }}</p>
138
+ <p class="truncate text-xs text-muted-foreground">{{ row[itemSubtitle] }}</p>
139
+ </div>
140
+ </div>
141
+ </slot>
142
+ </div>
143
+
144
+ <div v-if="!rows.length" class="text-center py-12 text-muted-foreground text-sm">Sin resultados</div>
145
+ <div ref="sentinel" class="flex justify-center py-4">
146
+ <IconLoader2 v-if="isFetchingNextPage" class="size-4 animate-spin text-muted-foreground" />
147
+ </div>
148
+ </template>
149
+ </div>
150
+ </aside>
151
+
152
+ <!-- ── Detalle ── -->
153
+ <section class="grow overflow-y-auto">
154
+ <!-- Vacío -->
155
+ <div v-if="!hasSelection" class="h-full flex flex-col items-center justify-center text-center text-muted-foreground px-6">
156
+ <slot name="empty">
157
+ <IconSearch class="size-10 mb-3 opacity-30" :stroke-width="1.5" />
158
+ <p class="text-sm">{{ emptyText }}</p>
159
+ </slot>
160
+ </div>
161
+
162
+ <!-- Skeleton detalle -->
163
+ <div v-else-if="loading" class="p-6 animate-pulse">
164
+ <div class="flex items-center gap-4">
165
+ <span class="size-16 rounded-full bg-surface-1 shrink-0" />
166
+ <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>
167
+ </div>
168
+ <div class="mt-8 space-y-3">
169
+ <span v-for="i in 4" :key="i" class="block h-4 bg-surface-1 rounded" :style="{ width: (90 - i * 12) + '%' }" />
170
+ </div>
171
+ </div>
172
+
173
+ <!-- Contenido -->
174
+ <div v-else>
175
+ <div v-if="$slots.header" class="p-6 border-b border-card-line">
176
+ <slot name="header" :id="activeId" />
177
+ </div>
178
+ <div class="p-6">
179
+ <slot :id="activeId" />
180
+ </div>
181
+ </div>
182
+ </section>
183
+ </div>
184
+ </template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@innertia-solutions/innertia-nuxt",
3
- "version": "0.1.43",
3
+ "version": "0.1.45",
4
4
  "description": "Innertia Solutions — Nuxt layer unificada: core, auth, multitenancy, theme y app contexts",
5
5
  "keywords": [
6
6
  "nuxt",