@innertia-solutions/innertia-nuxt 0.1.12 → 0.1.13

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.
Files changed (51) hide show
  1. package/components/Attention.vue +269 -0
  2. package/components/Chart/index.vue +193 -0
  3. package/components/Container.vue +116 -0
  4. package/components/CopyButton.vue +98 -0
  5. package/components/Files/Card.vue +215 -0
  6. package/components/Files/Preview.vue +675 -0
  7. package/components/Files/Upload.vue +408 -0
  8. package/components/Files/UploadModal.vue +83 -0
  9. package/components/Forms/InputPin.vue +162 -0
  10. package/components/InfoToolbar.vue +54 -0
  11. package/components/Kanban/Standard.vue +588 -0
  12. package/components/Loader.vue +358 -0
  13. package/components/Modal/Base.vue +6 -0
  14. package/components/Modal/Confirm.vue +89 -0
  15. package/components/Modal/DeleteConfirm.vue +14 -31
  16. package/components/Modal/Form.vue +76 -0
  17. package/components/Modal/Host.vue +58 -0
  18. package/components/Modal/Prompt.vue +74 -0
  19. package/components/Modal.vue +119 -34
  20. package/components/Table/Standard.vue +198 -25
  21. package/components/Table/index.vue +169 -369
  22. package/components/Table/views/ViewCards.vue +74 -0
  23. package/components/Table/views/ViewDatabase.vue +236 -0
  24. package/components/Table/views/ViewTable.vue +299 -0
  25. package/components/Toast/Alert.vue +16 -111
  26. package/components/Toast/Container.vue +4 -29
  27. package/components/Toast/Notification.vue +8 -43
  28. package/components/Toast/Process.vue +8 -86
  29. package/components/Toast/Stack.vue +59 -0
  30. package/components/Toast/Standard.vue +115 -0
  31. package/components/Tree/Standard.vue +49 -4
  32. package/components/Workflow/Board.vue +356 -0
  33. package/components/Workflow/Builder.vue +454 -0
  34. package/components/Workflow/Form.vue +526 -0
  35. package/components/Workflow/Viewer.vue +225 -0
  36. package/components/Workflow/edges/TransitionEdge.vue +84 -0
  37. package/components/Workflow/nodes/StepNode.vue +88 -0
  38. package/composables/useClipboard.js +79 -0
  39. package/composables/useConfetti.js +194 -0
  40. package/composables/useFile.js +189 -0
  41. package/composables/useModal.js +71 -0
  42. package/composables/useToast.js +38 -65
  43. package/composables/useWorkflow/form.js +162 -0
  44. package/composables/useWorkflow/index.js +18 -0
  45. package/composables/useWorkflow/view.js +220 -0
  46. package/composables/useWorkflowLayout.js +162 -0
  47. package/css/tokens.css +10 -10
  48. package/nuxt.config.ts +1 -1
  49. package/package.json +13 -8
  50. package/stores/modal.js +48 -0
  51. package/stores/toast.js +156 -117
@@ -0,0 +1,408 @@
1
+ <script setup>
2
+ import {
3
+ IconCloudUpload, IconX, IconCheck, IconAlertCircle, IconLoader2,
4
+ IconPlayerPause, IconRotateClockwise, IconTrash,
5
+ } from '@tabler/icons-vue'
6
+
7
+ /**
8
+ * <FilesUpload> — Zona de upload con drag & drop + cards per file.
9
+ *
10
+ * Estados por archivo: pending → uploading → processing → success | error
11
+ *
12
+ * - uploading: el XHR está enviando el binario (0-100% real)
13
+ * - processing: el cliente terminó (100%) pero el server aún no respondió.
14
+ * Se muestra como indeterminate hasta resolver.
15
+ *
16
+ * El consumer recibe `@uploaded(file)` por cada archivo exitoso y
17
+ * `@error({ file, error })` por cada fallo. `@complete(results)` al terminar todos.
18
+ */
19
+ const props = defineProps({
20
+ endpoint: { type: String, required: true },
21
+ field: { type: String, default: 'file' },
22
+ multiple: { type: Boolean, default: false },
23
+ accept: { type: String, default: '' },
24
+ maxSizeMb: { type: Number, default: 0 },
25
+ maxFiles: { type: Number, default: 0 },
26
+ autoUpload: { type: Boolean, default: true },
27
+ extraData: { type: Object, default: () => ({}) },
28
+ clearOnSuccess: { type: Boolean, default: false },
29
+ hint: { type: String, default: 'Arrastrá archivos o hacé click para seleccionar' },
30
+ hintSecondary: { type: String, default: '' },
31
+ })
32
+
33
+ const emit = defineEmits(['uploaded', 'error', 'complete', 'change'])
34
+
35
+ const { upload, formatSize, iconFor } = useFile()
36
+
37
+ const inputRef = ref(null)
38
+ const dragOver = ref(false)
39
+ const queue = ref([])
40
+
41
+ let nextId = 1
42
+ const newItem = (file) => ({
43
+ id: nextId++,
44
+ file,
45
+ name: file.name,
46
+ size: file.size,
47
+ type: file.type || 'application/octet-stream',
48
+ status: 'pending', // pending | uploading | processing | success | error
49
+ progress: 0,
50
+ loaded: 0,
51
+ speed: 0, // bytes/s
52
+ eta: 0, // segundos restantes
53
+ startedAt: 0,
54
+ response: null,
55
+ error: null,
56
+ controller: null,
57
+ })
58
+
59
+ watch(queue, (q) => emit('change', q), { deep: true })
60
+
61
+ const maxBytes = computed(() => props.maxSizeMb > 0 ? props.maxSizeMb * 1024 * 1024 : Infinity)
62
+ const validate = (file) => file.size > maxBytes.value ? `Excede ${props.maxSizeMb}MB` : null
63
+
64
+ const openPicker = () => inputRef.value?.click()
65
+
66
+ const addFiles = (fileList) => {
67
+ const incoming = Array.from(fileList)
68
+ if (!incoming.length) return
69
+
70
+ let toAdd = incoming
71
+ if (props.multiple) {
72
+ if (props.maxFiles > 0) {
73
+ const slots = props.maxFiles - queue.value.length
74
+ if (slots <= 0) return
75
+ toAdd = incoming.slice(0, slots)
76
+ }
77
+ } else {
78
+ queue.value = []
79
+ toAdd = [incoming[0]]
80
+ }
81
+
82
+ for (const f of toAdd) {
83
+ const item = newItem(f)
84
+ const err = validate(f)
85
+ if (err) { item.status = 'error'; item.error = err }
86
+ queue.value.push(item)
87
+ }
88
+
89
+ if (props.autoUpload) nextTick(uploadAll)
90
+ }
91
+
92
+ const uploadOne = async (item) => {
93
+ if (item.status !== 'pending') return
94
+ item.status = 'uploading'
95
+ item.startedAt = Date.now()
96
+ item.controller = new AbortController()
97
+ try {
98
+ const res = await upload(props.endpoint, item.file, {
99
+ field: props.field,
100
+ extraData: props.extraData,
101
+ signal: item.controller.signal,
102
+ onProgress: (p, loaded) => {
103
+ item.progress = p
104
+ item.loaded = loaded
105
+ const elapsed = (Date.now() - item.startedAt) / 1000
106
+ if (elapsed > 0) {
107
+ item.speed = loaded / elapsed
108
+ if (item.speed > 0) item.eta = Math.max(0, (item.size - loaded) / item.speed)
109
+ }
110
+ },
111
+ onUploaded: () => {
112
+ // Upload del cliente terminó, esperando server.
113
+ if (item.status === 'uploading') {
114
+ item.status = 'processing'
115
+ item.progress = 100
116
+ }
117
+ },
118
+ })
119
+ item.status = 'success'
120
+ item.progress = 100
121
+ item.response = res
122
+ emit('uploaded', res, item)
123
+ if (props.clearOnSuccess) setTimeout(() => removeItem(item.id), 800)
124
+ } catch (e) {
125
+ if (e?.aborted) return
126
+ item.status = 'error'
127
+ item.error = e?.data?.message ?? e.message ?? 'Error en la carga'
128
+ emit('error', { item, error: e })
129
+ }
130
+ }
131
+
132
+ const uploadAll = async () => {
133
+ const pending = queue.value.filter(i => i.status === 'pending')
134
+ if (!pending.length) return
135
+ await Promise.allSettled(pending.map(uploadOne))
136
+ emit('complete', {
137
+ success: queue.value.filter(i => i.status === 'success'),
138
+ failed: queue.value.filter(i => i.status === 'error'),
139
+ })
140
+ }
141
+
142
+ const retryItem = (item) => {
143
+ item.status = 'pending'
144
+ item.progress = 0
145
+ item.loaded = 0
146
+ item.error = null
147
+ uploadOne(item)
148
+ }
149
+
150
+ const removeItem = (id) => {
151
+ const idx = queue.value.findIndex(i => i.id === id)
152
+ if (idx === -1) return
153
+ const item = queue.value[idx]
154
+ if (item.status === 'uploading' || item.status === 'processing') item.controller?.abort()
155
+ queue.value.splice(idx, 1)
156
+ }
157
+
158
+ const clearAll = () => {
159
+ for (const it of queue.value) {
160
+ if (it.status === 'uploading' || it.status === 'processing') it.controller?.abort()
161
+ }
162
+ queue.value = []
163
+ }
164
+
165
+ const cancelItem = (item) => {
166
+ if (item.status === 'uploading' || item.status === 'processing') {
167
+ item.controller?.abort()
168
+ item.status = 'error'
169
+ item.error = 'Cancelado'
170
+ }
171
+ }
172
+
173
+ // ── Drag & drop ────────────────────────────────────────────────────────────
174
+ const onDragOver = (e) => { e.preventDefault(); dragOver.value = true }
175
+ const onDragLeave = (e) => { e.preventDefault(); dragOver.value = false }
176
+ const onDrop = (e) => {
177
+ e.preventDefault()
178
+ dragOver.value = false
179
+ if (e.dataTransfer?.files?.length) addFiles(e.dataTransfer.files)
180
+ }
181
+
182
+ const onInputChange = (e) => {
183
+ if (e.target?.files?.length) addFiles(e.target.files)
184
+ e.target.value = ''
185
+ }
186
+
187
+ // ── Helpers de UI ──────────────────────────────────────────────────────────
188
+ const formatSpeed = (bps) => {
189
+ if (!bps || bps < 1) return ''
190
+ if (bps < 1024) return `${bps.toFixed(0)} B/s`
191
+ if (bps < 1024 * 1024) return `${(bps / 1024).toFixed(1)} KB/s`
192
+ return `${(bps / 1024 / 1024).toFixed(1)} MB/s`
193
+ }
194
+ const formatEta = (s) => {
195
+ if (!s || !isFinite(s) || s < 1) return ''
196
+ if (s < 60) return `${Math.ceil(s)}s`
197
+ return `${Math.ceil(s / 60)}m`
198
+ }
199
+
200
+ const summary = computed(() => {
201
+ const total = queue.value.length
202
+ const ok = queue.value.filter(i => i.status === 'success').length
203
+ const err = queue.value.filter(i => i.status === 'error').length
204
+ const active = queue.value.filter(i => i.status === 'uploading' || i.status === 'processing').length
205
+ return { total, ok, err, active }
206
+ })
207
+
208
+ defineExpose({ open: openPicker, uploadAll, clearAll, removeItem, queue })
209
+ </script>
210
+
211
+ <template>
212
+ <div class="space-y-3">
213
+ <!-- ── Dropzone ────────────────────────────────────────────────────── -->
214
+ <button
215
+ type="button"
216
+ :class="[
217
+ 'w-full flex flex-col items-center justify-center gap-2 px-4 py-8 rounded-card border-2 border-dashed transition-colors text-center',
218
+ dragOver
219
+ ? 'border-primary bg-primary/5 text-primary'
220
+ : 'border-card-line hover:border-primary/40 hover:bg-muted-hover/30 text-muted-foreground',
221
+ ]"
222
+ @click="openPicker"
223
+ @dragover.prevent="onDragOver"
224
+ @dragleave.prevent="onDragLeave"
225
+ @drop.prevent="onDrop"
226
+ >
227
+ <IconCloudUpload :size="32" :stroke="1.5" />
228
+ <div>
229
+ <p class="text-sm font-medium text-foreground">{{ hint }}</p>
230
+ <p v-if="hintSecondary" class="text-xs text-muted-foreground mt-1">{{ hintSecondary }}</p>
231
+ <p v-else-if="maxSizeMb || accept" class="text-xs text-muted-foreground mt-1">
232
+ <span v-if="accept">{{ accept }}</span>
233
+ <span v-if="accept && maxSizeMb"> · </span>
234
+ <span v-if="maxSizeMb">máx. {{ maxSizeMb }}MB por archivo</span>
235
+ </p>
236
+ </div>
237
+
238
+ <input
239
+ ref="inputRef"
240
+ type="file"
241
+ class="hidden"
242
+ :multiple="multiple"
243
+ :accept="accept || undefined"
244
+ @change="onInputChange"
245
+ />
246
+ </button>
247
+
248
+ <!-- ── Summary chip ─────────────────────────────────────────────────── -->
249
+ <div v-if="queue.length" class="flex items-center justify-between text-xs">
250
+ <div class="flex items-center gap-3 text-muted-foreground">
251
+ <span>{{ summary.total }} archivo{{ summary.total === 1 ? '' : 's' }}</span>
252
+ <span v-if="summary.active" class="text-primary inline-flex items-center gap-1">
253
+ <IconLoader2 :size="12" class="animate-spin" /> {{ summary.active }} en progreso
254
+ </span>
255
+ <span v-if="summary.ok" class="text-emerald-600 dark:text-emerald-400">{{ summary.ok }} ok</span>
256
+ <span v-if="summary.err" class="text-red-500">{{ summary.err }} con error</span>
257
+ </div>
258
+ <button
259
+ type="button"
260
+ class="inline-flex items-center gap-1 px-2 py-1 rounded-control text-muted-foreground hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
261
+ @click="clearAll"
262
+ >
263
+ <IconTrash :size="12" /> Limpiar
264
+ </button>
265
+ </div>
266
+
267
+ <!-- ── Cards per file (estilo Preline file-uploading-progress-form) ── -->
268
+ <TransitionGroup
269
+ v-if="queue.length"
270
+ tag="ul"
271
+ class="space-y-2"
272
+ enter-active-class="transition ease-out duration-200"
273
+ enter-from-class="opacity-0 -translate-y-1"
274
+ enter-to-class="opacity-100 translate-y-0"
275
+ leave-active-class="transition ease-in duration-150"
276
+ leave-from-class="opacity-100"
277
+ leave-to-class="opacity-0"
278
+ >
279
+ <li
280
+ v-for="item in queue"
281
+ :key="item.id"
282
+ :class="[
283
+ 'relative p-3 rounded-card border bg-card transition-colors',
284
+ item.status === 'error' ? 'border-red-500/30 bg-red-500/[0.02]'
285
+ : item.status === 'success' ? 'border-emerald-500/30 bg-emerald-500/[0.02]'
286
+ : 'border-card-line',
287
+ ]"
288
+ >
289
+ <div class="flex items-start gap-3">
290
+ <!-- File type icon -->
291
+ <div
292
+ :class="[
293
+ 'shrink-0 size-10 rounded-control flex items-center justify-center',
294
+ item.status === 'error' ? 'bg-red-500/10 text-red-600 dark:text-red-400'
295
+ : item.status === 'success' ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
296
+ : 'bg-muted text-muted-foreground',
297
+ ]"
298
+ >
299
+ <component
300
+ :is="item.status === 'success' ? IconCheck
301
+ : item.status === 'error' ? IconAlertCircle
302
+ : iconFor(item.type)"
303
+ :size="20"
304
+ :stroke="1.75"
305
+ />
306
+ </div>
307
+
308
+ <!-- Body -->
309
+ <div class="flex-1 min-w-0">
310
+ <div class="flex items-start justify-between gap-2">
311
+ <div class="min-w-0 flex-1">
312
+ <p class="text-sm font-medium text-foreground truncate">{{ item.name }}</p>
313
+ <p class="text-[11px] text-muted-foreground mt-0.5 tabular-nums">
314
+ <template v-if="item.status === 'uploading'">
315
+ <span>{{ formatSize(item.loaded) }} / {{ formatSize(item.size) }}</span>
316
+ <span v-if="item.speed" class="mx-1.5">·</span>
317
+ <span v-if="item.speed">{{ formatSpeed(item.speed) }}</span>
318
+ <span v-if="item.eta" class="mx-1.5">·</span>
319
+ <span v-if="item.eta">{{ formatEta(item.eta) }} restantes</span>
320
+ </template>
321
+ <template v-else-if="item.status === 'processing'">
322
+ <span class="text-primary inline-flex items-center gap-1">
323
+ <IconLoader2 :size="11" class="animate-spin" /> Procesando en el servidor…
324
+ </span>
325
+ </template>
326
+ <template v-else-if="item.status === 'success'">
327
+ <span class="text-emerald-600 dark:text-emerald-400">Subido</span>
328
+ <span class="mx-1.5">·</span>
329
+ <span>{{ formatSize(item.size) }}</span>
330
+ </template>
331
+ <template v-else-if="item.status === 'error'">
332
+ <span class="text-red-500">{{ item.error }}</span>
333
+ </template>
334
+ <template v-else>
335
+ <span>{{ formatSize(item.size) }}</span>
336
+ </template>
337
+ </p>
338
+ </div>
339
+
340
+ <!-- Actions -->
341
+ <div class="flex items-center gap-0.5 shrink-0">
342
+ <button
343
+ v-if="item.status === 'error'"
344
+ type="button"
345
+ title="Reintentar"
346
+ class="inline-flex items-center justify-center size-7 rounded-control text-primary hover:bg-primary/10 transition-colors"
347
+ @click="retryItem(item)"
348
+ >
349
+ <IconRotateClockwise class="size-3.5" />
350
+ </button>
351
+ <button
352
+ v-if="item.status === 'uploading' || item.status === 'processing'"
353
+ type="button"
354
+ title="Cancelar"
355
+ class="inline-flex items-center justify-center size-7 rounded-control text-muted-foreground hover:text-amber-500 hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors"
356
+ @click="cancelItem(item)"
357
+ >
358
+ <IconPlayerPause class="size-3.5" />
359
+ </button>
360
+ <button
361
+ type="button"
362
+ title="Quitar"
363
+ class="inline-flex items-center justify-center size-7 rounded-control text-muted-foreground hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
364
+ @click="removeItem(item.id)"
365
+ >
366
+ <IconX class="size-3.5" />
367
+ </button>
368
+ </div>
369
+ </div>
370
+
371
+ <!-- Progress bar (uploading + processing) -->
372
+ <div
373
+ v-if="item.status === 'uploading' || item.status === 'processing'"
374
+ class="mt-2 flex items-center gap-2"
375
+ >
376
+ <div class="flex-1 h-1.5 rounded-full bg-muted overflow-hidden relative">
377
+ <!-- Determinate progress -->
378
+ <div
379
+ v-if="item.status === 'uploading'"
380
+ class="h-full bg-primary transition-all duration-150"
381
+ :style="{ width: item.progress + '%' }"
382
+ />
383
+ <!-- Indeterminate shimmer for processing -->
384
+ <div
385
+ v-else
386
+ class="absolute inset-0 bg-gradient-to-r from-transparent via-primary to-transparent indeterminate-shimmer"
387
+ />
388
+ </div>
389
+ <span class="text-[11px] font-mono tabular-nums text-muted-foreground w-10 text-right">
390
+ {{ item.status === 'uploading' ? `${item.progress}%` : '…' }}
391
+ </span>
392
+ </div>
393
+ </div>
394
+ </div>
395
+ </li>
396
+ </TransitionGroup>
397
+ </div>
398
+ </template>
399
+
400
+ <style scoped>
401
+ @keyframes shimmer {
402
+ 0% { transform: translateX(-100%); }
403
+ 100% { transform: translateX(100%); }
404
+ }
405
+ .indeterminate-shimmer {
406
+ animation: shimmer 1.2s ease-in-out infinite;
407
+ }
408
+ </style>
@@ -0,0 +1,83 @@
1
+ <script setup>
2
+ /**
3
+ * <FilesUploadModal> — `<FilesUpload>` wrappeado en `<Modal>`.
4
+ *
5
+ * Uso:
6
+ * <FilesUploadModal
7
+ * v-model:open="showUpload"
8
+ * endpoint="backoffice/invoices/123/files"
9
+ * title="Adjuntar comprobante"
10
+ * :max-size-mb="10"
11
+ * accept=".pdf"
12
+ * @uploaded="(file) => attach(file)"
13
+ * />
14
+ */
15
+ const props = defineProps({
16
+ /** Endpoint multipart (idem FilesUpload). */
17
+ endpoint: { type: String, required: true },
18
+
19
+ /** Título del modal. */
20
+ title: { type: String, default: 'Subir archivo' },
21
+
22
+ /** Tamaño del modal. */
23
+ size: { type: String, default: 'lg' },
24
+
25
+ // Pass-through a FilesUpload
26
+ field: { type: String, default: 'file' },
27
+ multiple: { type: Boolean, default: false },
28
+ accept: { type: String, default: '' },
29
+ maxSizeMb: { type: Number, default: 0 },
30
+ maxFiles: { type: Number, default: 0 },
31
+ autoUpload: { type: Boolean, default: true },
32
+ extraData: { type: Object, default: () => ({}) },
33
+ hint: { type: String, default: 'Arrastrá archivos o hacé click para seleccionar' },
34
+ hintSecondary: { type: String, default: '' },
35
+
36
+ /** Cerrar el modal automáticamente cuando todos los uploads terminan exitosos. */
37
+ closeOnComplete: { type: Boolean, default: true },
38
+ })
39
+
40
+ const open = defineModel('open', { default: false })
41
+
42
+ const emit = defineEmits(['uploaded', 'error', 'complete'])
43
+
44
+ const uploadRef = ref(null)
45
+
46
+ const handleComplete = (results) => {
47
+ emit('complete', results)
48
+ if (props.closeOnComplete && results.failed.length === 0 && results.success.length > 0) {
49
+ // Pequeño delay para que el usuario vea los checkmarks verdes antes de cerrar
50
+ setTimeout(() => { open.value = false }, 600)
51
+ }
52
+ }
53
+
54
+ watch(open, (v) => {
55
+ if (!v) uploadRef.value?.clearAll()
56
+ })
57
+ </script>
58
+
59
+ <template>
60
+ <Modal
61
+ v-model="open"
62
+ :title="title"
63
+ :size="size"
64
+ :backdrop-dismiss="false"
65
+ >
66
+ <FilesUpload
67
+ ref="uploadRef"
68
+ :endpoint="endpoint"
69
+ :field="field"
70
+ :multiple="multiple"
71
+ :accept="accept"
72
+ :max-size-mb="maxSizeMb"
73
+ :max-files="maxFiles"
74
+ :auto-upload="autoUpload"
75
+ :extra-data="extraData"
76
+ :hint="hint"
77
+ :hint-secondary="hintSecondary"
78
+ @uploaded="(file, item) => emit('uploaded', file, item)"
79
+ @error="(err) => emit('error', err)"
80
+ @complete="handleComplete"
81
+ />
82
+ </Modal>
83
+ </template>
@@ -0,0 +1,162 @@
1
+ <script setup>
2
+ /**
3
+ * <FormsInputPin> — Input para códigos PIN / OTP.
4
+ *
5
+ * Características:
6
+ * - Longitud configurable (default 4, soporta 4-8 dígitos)
7
+ * - Acepta solo dígitos (o letras si type="text")
8
+ * - Auto-focus al siguiente al escribir, retroceso al anterior con Backspace
9
+ * - Paste inteligente — pegás "123456" y se distribuye
10
+ * - Estados: default, error, success (vía prop o slot)
11
+ * - Mask opcional para passwords / códigos sensibles
12
+ * - Emite @complete cuando todos los slots están llenos
13
+ *
14
+ * ```vue
15
+ * <FormsInputPin v-model="otp" :length="6" @complete="verify" />
16
+ * ```
17
+ */
18
+ const props = defineProps({
19
+ modelValue: { type: String, default: '' },
20
+ length: { type: Number, default: 6 },
21
+ /** 'number' (default) acepta solo dígitos, 'text' acepta letras también. */
22
+ type: { type: String, default: 'number', validator: v => ['number','text'].includes(v) },
23
+ /** Oculta los caracteres (estilo password). */
24
+ masked: { type: Boolean, default: false },
25
+ disabled: { type: Boolean, default: false },
26
+ /** Estado de validación visual. */
27
+ state: { type: String, default: 'default', validator: v => ['default','error','success'].includes(v) },
28
+ size: { type: String, default: 'md', validator: v => ['sm','md','lg'].includes(v) },
29
+ /** Separador visual cada N slots. Ej. 3 para "123-456". */
30
+ separator: { type: Number, default: 0 },
31
+ autofocus: { type: Boolean, default: false },
32
+ })
33
+
34
+ const emit = defineEmits(['update:modelValue', 'complete', 'change'])
35
+
36
+ const inputs = ref([])
37
+ const slots = ref(Array.from({ length: props.length }, (_, i) =>
38
+ (props.modelValue || '').charAt(i) || ''
39
+ ))
40
+
41
+ watch(() => props.modelValue, (val) => {
42
+ const arr = Array.from({ length: props.length }, (_, i) => (val || '').charAt(i) || '')
43
+ if (arr.join('') !== slots.value.join('')) slots.value = arr
44
+ })
45
+
46
+ watch(() => props.length, (n) => {
47
+ const cur = slots.value.join('').slice(0, n)
48
+ slots.value = Array.from({ length: n }, (_, i) => cur.charAt(i) || '')
49
+ })
50
+
51
+ const sanitize = (ch) => {
52
+ if (!ch) return ''
53
+ if (props.type === 'number') return /[0-9]/.test(ch) ? ch : ''
54
+ return /[a-zA-Z0-9]/.test(ch) ? ch : ''
55
+ }
56
+
57
+ const sync = () => {
58
+ const v = slots.value.join('')
59
+ emit('update:modelValue', v)
60
+ emit('change', v)
61
+ if (v.length === props.length && !v.includes('')) emit('complete', v)
62
+ }
63
+
64
+ const focusSlot = async (i) => {
65
+ await nextTick()
66
+ inputs.value[i]?.focus()
67
+ inputs.value[i]?.select?.()
68
+ }
69
+
70
+ const onInput = (e, i) => {
71
+ const raw = e.target.value
72
+ const last = raw.slice(-1)
73
+ const ok = sanitize(last)
74
+ slots.value[i] = ok
75
+ e.target.value = ok
76
+ sync()
77
+ if (ok && i < props.length - 1) focusSlot(i + 1)
78
+ }
79
+
80
+ const onKeydown = (e, i) => {
81
+ if (e.key === 'Backspace') {
82
+ if (slots.value[i]) {
83
+ slots.value[i] = ''
84
+ sync()
85
+ } else if (i > 0) {
86
+ slots.value[i - 1] = ''
87
+ sync()
88
+ focusSlot(i - 1)
89
+ }
90
+ e.preventDefault()
91
+ } else if (e.key === 'ArrowLeft' && i > 0) {
92
+ focusSlot(i - 1)
93
+ } else if (e.key === 'ArrowRight' && i < props.length - 1) {
94
+ focusSlot(i + 1)
95
+ } else if (e.key === 'Enter') {
96
+ const v = slots.value.join('')
97
+ if (v.length === props.length && !v.includes('')) emit('complete', v)
98
+ }
99
+ }
100
+
101
+ const onPaste = (e, i) => {
102
+ e.preventDefault()
103
+ const text = (e.clipboardData?.getData('text') ?? '').trim()
104
+ if (!text) return
105
+ let j = i
106
+ for (const ch of text) {
107
+ const ok = sanitize(ch)
108
+ if (!ok) continue
109
+ if (j >= props.length) break
110
+ slots.value[j] = ok
111
+ j++
112
+ }
113
+ sync()
114
+ focusSlot(Math.min(j, props.length - 1))
115
+ }
116
+
117
+ const onFocus = (e) => e.target.select?.()
118
+
119
+ onMounted(() => {
120
+ if (props.autofocus) focusSlot(0)
121
+ })
122
+
123
+ const slotClass = computed(() => {
124
+ const sizeMap = {
125
+ sm: 'size-9 text-base',
126
+ md: 'size-11 text-lg',
127
+ lg: 'size-14 text-2xl',
128
+ }[props.size]
129
+ const stateMap = {
130
+ default: 'border-card-line focus:border-primary focus:ring-2 focus:ring-primary/20',
131
+ error: 'border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-500/20 text-red-600',
132
+ success: 'border-emerald-500 focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 text-emerald-600',
133
+ }[props.state]
134
+ return `${sizeMap} ${stateMap} text-center font-mono font-semibold tabular-nums bg-card text-foreground rounded-control border outline-none transition-colors`
135
+ })
136
+ </script>
137
+
138
+ <template>
139
+ <div class="inline-flex items-center gap-1.5">
140
+ <template v-for="(_, i) in slots" :key="i">
141
+ <input
142
+ :ref="el => inputs[i] = el"
143
+ :type="masked ? 'password' : (type === 'number' ? 'tel' : 'text')"
144
+ :inputmode="type === 'number' ? 'numeric' : 'text'"
145
+ :pattern="type === 'number' ? '[0-9]*' : null"
146
+ :value="slots[i]"
147
+ :disabled="disabled"
148
+ :maxlength="1"
149
+ autocomplete="one-time-code"
150
+ :class="slotClass"
151
+ @input="e => onInput(e, i)"
152
+ @keydown="e => onKeydown(e, i)"
153
+ @paste="e => onPaste(e, i)"
154
+ @focus="onFocus"
155
+ />
156
+ <span
157
+ v-if="separator > 0 && (i + 1) % separator === 0 && i < length - 1"
158
+ class="text-muted-foreground text-xl select-none"
159
+ >–</span>
160
+ </template>
161
+ </div>
162
+ </template>