@innertia-solutions/innertia-nuxt 0.1.42 → 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.
@@ -0,0 +1,99 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, watch, nextTick } from 'vue'
3
+
4
+ /*
5
+ * CurrencyInput — input de moneda con formato SOLO visual.
6
+ * El `v-model` es SIEMPRE un número puro (o null); lo que se muestra es el valor
7
+ * agrupado con separador de miles y prefijo (ej. "$2.234.234"). Pensado para CLP
8
+ * (entero, sin decimales); `locale`/`prefix` lo hacen reusable.
9
+ */
10
+ const props = withDefaults(defineProps<{
11
+ label?: string
12
+ placeholder?: string
13
+ disabled?: boolean
14
+ error?: string | null
15
+ hint?: string
16
+ size?: 'sm' | 'md'
17
+ prefix?: string
18
+ locale?: string
19
+ min?: number | null
20
+ }>(), {
21
+ prefix: '$',
22
+ locale: 'es-CL',
23
+ min: null,
24
+ })
25
+
26
+ const model = defineModel<number | null>({ default: null })
27
+
28
+ const inputEl = ref<HTMLInputElement | null>(null)
29
+ const focused = ref(false)
30
+
31
+ const nf = computed(() => new Intl.NumberFormat(props.locale, {
32
+ minimumFractionDigits: 0,
33
+ maximumFractionDigits: 0,
34
+ }))
35
+
36
+ function format(n: number | null | undefined): string {
37
+ if (n === null || n === undefined || Number.isNaN(n)) return ''
38
+ return props.prefix + nf.value.format(n)
39
+ }
40
+
41
+ function parse(str: string): number | null {
42
+ const digits = (str || '').replace(/\D/g, '')
43
+ if (!digits) return null
44
+ return Number(digits)
45
+ }
46
+
47
+ const display = ref(format(model.value))
48
+
49
+ // Mantener el display en sync cuando el valor cambia desde fuera (no mientras escribe).
50
+ watch(model, (v) => {
51
+ if (!focused.value) display.value = format(v)
52
+ })
53
+
54
+ function onInput(e: Event) {
55
+ const el = e.target as HTMLInputElement
56
+ let n = parse(el.value)
57
+ if (n !== null && props.min !== null && n < props.min) n = props.min
58
+ model.value = n
59
+ display.value = format(n)
60
+ // Caret al final tras reformatear (evita saltos raros al agrupar).
61
+ nextTick(() => {
62
+ const len = inputEl.value?.value.length ?? 0
63
+ inputEl.value?.setSelectionRange(len, len)
64
+ })
65
+ }
66
+
67
+ function onBlur() {
68
+ focused.value = false
69
+ display.value = format(model.value)
70
+ }
71
+
72
+ const baseClasses = computed(() =>
73
+ `innertia-field${props.size === 'sm' ? ' innertia-field-sm' : ''}`,
74
+ )
75
+ </script>
76
+
77
+ <template>
78
+ <div class="w-full">
79
+ <label v-if="label" class="block text-sm font-medium text-foreground mb-1.5">
80
+ {{ label }}
81
+ </label>
82
+
83
+ <input
84
+ ref="inputEl"
85
+ type="text"
86
+ inputmode="numeric"
87
+ :value="display"
88
+ :placeholder="placeholder"
89
+ :disabled="disabled"
90
+ :class="[baseClasses, error ? '!border-red-400 dark:!border-red-500' : '']"
91
+ @input="onInput"
92
+ @focus="focused = true"
93
+ @blur="onBlur"
94
+ />
95
+
96
+ <p v-if="error" class="text-xs text-red-500 dark:text-red-400 mt-1">{{ error }}</p>
97
+ <p v-else-if="hint" class="text-xs text-muted-foreground mt-1">{{ hint }}</p>
98
+ </div>
99
+ </template>
@@ -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,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>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@innertia-solutions/innertia-nuxt",
3
- "version": "0.1.42",
3
+ "version": "0.1.44",
4
4
  "description": "Innertia Solutions — Nuxt layer unificada: core, auth, multitenancy, theme y app contexts",
5
5
  "keywords": [
6
6
  "nuxt",