@innertia-solutions/innertia-nuxt 0.1.41 → 0.1.42

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,378 @@
1
+ <script setup>
2
+ import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
3
+ import { IconClock, IconX } from '@tabler/icons-vue'
4
+
5
+ /*
6
+ * TimePicker — selector de hora estilo Preline (dropdown con columnas), construido
7
+ * con los tokens del design system del lib. Sin dependencias externas.
8
+ *
9
+ * Valor (v-model): SIEMPRE 24h. "HH:mm" por defecto; "HH:mm:ss" si `seconds`.
10
+ * El display puede ser 12h (AM/PM) si `hour12`, pero el valor guardado sigue en 24h.
11
+ */
12
+
13
+ const props = defineProps({
14
+ hour12: { type: Boolean, default: false },
15
+ seconds: { type: Boolean, default: false },
16
+ step: { type: Number, default: 1 }, // incremento de la columna de minutos
17
+ minTime: { type: String, default: null }, // "HH:mm[:ss]" 24h
18
+ maxTime: { type: String, default: null },
19
+ placeholder: { type: String, default: 'Seleccionar hora' },
20
+ disabled: { type: Boolean, default: false },
21
+ clearable: { type: Boolean, default: true },
22
+ label: { type: String, default: '' },
23
+ error: { type: String, default: null },
24
+ hint: { type: String, default: '' },
25
+ size: { type: String, default: 'md' }, // 'sm' | 'md'
26
+ })
27
+
28
+ const modelValue = defineModel({ type: String, default: null })
29
+ const emit = defineEmits(['change'])
30
+
31
+ /* ── Helpers puros ─────────────────────────────────────────────────────── */
32
+
33
+ const pad = (n) => String(n).padStart(2, '0')
34
+
35
+ // "HH:mm" | "HH:mm:ss" → { h, m, s } (24h) | null
36
+ function parseValue (str) {
37
+ if (!str || typeof str !== 'string') return null
38
+ const parts = str.split(':').map((p) => parseInt(p, 10))
39
+ const [h, m, s = 0] = parts
40
+ if ([h, m, s].some((n) => Number.isNaN(n))) return null
41
+ if (h < 0 || h > 23 || m < 0 || m > 59 || s < 0 || s > 59) return null
42
+ return { h, m, s }
43
+ }
44
+
45
+ // { h, m, s } → "HH:mm" | "HH:mm:ss"
46
+ function composeValue (h, m, s, withSeconds) {
47
+ return withSeconds ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${pad(h)}:${pad(m)}`
48
+ }
49
+
50
+ // 24h hour → { h12, period }
51
+ function to12 (h) {
52
+ const period = h < 12 ? 'AM' : 'PM'
53
+ const h12 = h % 12 === 0 ? 12 : h % 12
54
+ return { h12, period }
55
+ }
56
+
57
+ // (displayHour 1-12, period) → 24h hour
58
+ function to24 (h12, period) {
59
+ const base = h12 % 12 // 12 → 0
60
+ return period === 'PM' ? base + 12 : base
61
+ }
62
+
63
+ // "HH:mm[:ss]" → segundos del día | null
64
+ function boundToSeconds (str) {
65
+ const p = parseValue(str)
66
+ return p ? p.h * 3600 + p.m * 60 + p.s : null
67
+ }
68
+
69
+ function partsToSeconds (h, m, s) {
70
+ return h * 3600 + m * 60 + s
71
+ }
72
+
73
+ /* ── Estado interno (24h) ──────────────────────────────────────────────── */
74
+
75
+ const open = ref(false)
76
+ const rootEl = ref(null)
77
+ const panelEl = ref(null)
78
+
79
+ const h = ref(null) // 0-23
80
+ const m = ref(null) // 0-59
81
+ const s = ref(null) // 0-59
82
+
83
+ // Sincronizar desde el modelValue externo
84
+ watch(modelValue, (val) => {
85
+ const parsed = parseValue(val)
86
+ if (parsed) {
87
+ h.value = parsed.h; m.value = parsed.m; s.value = parsed.s
88
+ } else {
89
+ h.value = null; m.value = null; s.value = null
90
+ }
91
+ }, { immediate: true })
92
+
93
+ const isComplete = computed(() =>
94
+ h.value !== null && m.value !== null && (!props.seconds || s.value !== null)
95
+ )
96
+
97
+ const period = computed(() => (h.value === null ? 'AM' : to12(h.value).period))
98
+ const displayHour = computed(() => (h.value === null ? null : to12(h.value).h12))
99
+
100
+ /* ── Display ───────────────────────────────────────────────────────────── */
101
+
102
+ const displayValue = computed(() => {
103
+ if (!isComplete.value) return ''
104
+ const base = props.hour12
105
+ ? `${pad(to12(h.value).h12)}:${pad(m.value)}${props.seconds ? ':' + pad(s.value) : ''} ${to12(h.value).period}`
106
+ : composeValue(h.value, m.value, s.value ?? 0, props.seconds)
107
+ return base
108
+ })
109
+
110
+ /* ── Opciones de columnas ──────────────────────────────────────────────── */
111
+
112
+ const hourOptions = computed(() =>
113
+ props.hour12
114
+ ? Array.from({ length: 12 }, (_, i) => i + 1) // 1..12
115
+ : Array.from({ length: 24 }, (_, i) => i) // 0..23
116
+ )
117
+ const minuteOptions = computed(() => {
118
+ const step = props.step > 0 ? props.step : 1
119
+ return Array.from({ length: Math.ceil(60 / step) }, (_, i) => i * step).filter((n) => n < 60)
120
+ })
121
+ const secondOptions = computed(() => Array.from({ length: 60 }, (_, i) => i))
122
+
123
+ /* ── Deshabilitar opciones fuera de [minTime, maxTime] ─────────────────── */
124
+
125
+ const minSec = computed(() => boundToSeconds(props.minTime))
126
+ const maxSec = computed(() => boundToSeconds(props.maxTime))
127
+
128
+ // ¿La hora resultante (usando el candidato en `col` y las otras partes actuales,
129
+ // con 0 por defecto) cae fuera de rango?
130
+ function outOfRange (col, candidate24hOrValue) {
131
+ if (minSec.value === null && maxSec.value === null) return false
132
+ let hh = h.value ?? 0
133
+ let mm = m.value ?? 0
134
+ let ss = props.seconds ? (s.value ?? 0) : 0
135
+ if (col === 'hour') hh = candidate24hOrValue
136
+ if (col === 'minute') mm = candidate24hOrValue
137
+ if (col === 'second') ss = candidate24hOrValue
138
+ const total = partsToSeconds(hh, mm, ss)
139
+ if (minSec.value !== null && total < minSec.value) return true
140
+ if (maxSec.value !== null && total > maxSec.value) return true
141
+ return false
142
+ }
143
+
144
+ // candidato de hora en 24h a partir de la opción visible (respeta el período en 12h)
145
+ function hourCandidate (opt) {
146
+ return props.hour12 ? to24(opt, period.value) : opt
147
+ }
148
+ function isHourDisabled (opt) { return outOfRange('hour', hourCandidate(opt)) }
149
+ function isMinuteDisabled (opt) { return outOfRange('minute', opt) }
150
+ function isSecondDisabled (opt) { return outOfRange('second', opt) }
151
+
152
+ /* ── Selección ─────────────────────────────────────────────────────────── */
153
+
154
+ function commit () {
155
+ // Rellenar partes faltantes con 0 al primer toque para formar una hora válida
156
+ if (h.value === null) h.value = 0
157
+ if (m.value === null) m.value = 0
158
+ if (props.seconds && s.value === null) s.value = 0
159
+ if (!isComplete.value) return
160
+ const val = composeValue(h.value, m.value, s.value ?? 0, props.seconds)
161
+ modelValue.value = val
162
+ emit('change', val)
163
+ }
164
+
165
+ function selectHour (opt) {
166
+ if (isHourDisabled(opt)) return
167
+ h.value = hourCandidate(opt)
168
+ commit()
169
+ }
170
+ function selectMinute (opt) {
171
+ if (isMinuteDisabled(opt)) return
172
+ m.value = opt
173
+ commit()
174
+ }
175
+ function selectSecond (opt) {
176
+ if (isSecondDisabled(opt)) return
177
+ s.value = opt
178
+ commit()
179
+ }
180
+ function selectPeriod (p) {
181
+ const dh = displayHour.value ?? 12
182
+ h.value = to24(dh, p)
183
+ commit()
184
+ }
185
+
186
+ function isHourSelected (opt) {
187
+ if (h.value === null) return false
188
+ return props.hour12 ? displayHour.value === opt : h.value === opt
189
+ }
190
+
191
+ function setNow () {
192
+ const now = new Date()
193
+ h.value = now.getHours()
194
+ const step = props.step > 0 ? props.step : 1
195
+ // snap al step, garantizando que caiga en una opción válida (0..59)
196
+ let snapped = Math.round(now.getMinutes() / step) * step
197
+ if (snapped > 59) snapped = Math.floor(59 / step) * step
198
+ m.value = snapped
199
+ s.value = props.seconds ? now.getSeconds() : 0
200
+ commit()
201
+ scrollSelectedIntoView()
202
+ }
203
+
204
+ function clear () {
205
+ h.value = null; m.value = null; s.value = null
206
+ modelValue.value = null
207
+ emit('change', null)
208
+ }
209
+
210
+ /* ── Apertura / cierre ─────────────────────────────────────────────────── */
211
+
212
+ function toggle () {
213
+ if (props.disabled) return
214
+ open.value = !open.value
215
+ }
216
+ function close () { open.value = false }
217
+
218
+ function onDocClick (e) {
219
+ if (open.value && rootEl.value && !rootEl.value.contains(e.target)) close()
220
+ }
221
+ function onKeydown (e) {
222
+ if (e.key === 'Escape' && open.value) close()
223
+ }
224
+
225
+ function scrollSelectedIntoView () {
226
+ nextTick(() => {
227
+ if (!panelEl.value) return
228
+ panelEl.value.querySelectorAll('[data-selected="true"]').forEach((el) => {
229
+ el.scrollIntoView({ block: 'center' })
230
+ })
231
+ })
232
+ }
233
+
234
+ watch(open, (isOpen) => { if (isOpen) scrollSelectedIntoView() })
235
+
236
+ onMounted(() => {
237
+ document.addEventListener('mousedown', onDocClick)
238
+ document.addEventListener('keydown', onKeydown)
239
+ })
240
+ onBeforeUnmount(() => {
241
+ document.removeEventListener('mousedown', onDocClick)
242
+ document.removeEventListener('keydown', onKeydown)
243
+ })
244
+
245
+ const fieldClasses = computed(() => [
246
+ 'innertia-field',
247
+ props.size === 'sm' ? 'innertia-field-sm' : '',
248
+ 'pe-10 cursor-pointer',
249
+ props.error ? '!border-red-400 dark:!border-red-500' : '',
250
+ ])
251
+ </script>
252
+
253
+ <template>
254
+ <div ref="rootEl" class="w-full">
255
+ <label v-if="label" class="block text-sm font-medium text-foreground mb-1.5">
256
+ {{ label }}
257
+ </label>
258
+
259
+ <div class="relative">
260
+ <div class="relative group">
261
+ <input
262
+ type="text"
263
+ :value="displayValue"
264
+ :disabled="disabled"
265
+ :placeholder="placeholder"
266
+ :class="fieldClasses"
267
+ readonly
268
+ @click="toggle"
269
+ />
270
+
271
+ <!-- Limpiar (con valor) o ícono reloj (vacío) -->
272
+ <button
273
+ v-if="clearable && displayValue && !disabled"
274
+ type="button"
275
+ class="absolute inset-y-0 end-0 flex items-center z-20 pe-3 text-muted-foreground hover:text-destructive transition-colors focus:outline-none"
276
+ @click.stop="clear"
277
+ >
278
+ <IconX class="size-4" />
279
+ </button>
280
+ <div
281
+ v-else
282
+ class="absolute inset-y-0 end-0 flex items-center pointer-events-none z-20 pe-3"
283
+ >
284
+ <IconClock class="size-4 text-muted-foreground-2 group-focus-within:text-primary transition-colors" />
285
+ </div>
286
+ </div>
287
+
288
+ <!-- Panel dropdown -->
289
+ <div
290
+ v-if="open"
291
+ ref="panelEl"
292
+ class="absolute z-50 mt-1 w-full min-w-56 bg-card border border-card-line rounded-card shadow-lg p-2"
293
+ >
294
+ <div class="flex gap-1">
295
+ <!-- Horas -->
296
+ <ul class="flex-1 max-h-56 overflow-y-auto pe-1 [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-card-line">
297
+ <li v-for="opt in hourOptions" :key="'h' + opt">
298
+ <button
299
+ type="button"
300
+ :disabled="isHourDisabled(opt)"
301
+ :data-selected="isHourSelected(opt)"
302
+ class="w-full text-center text-sm rounded-control px-2 py-1.5 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
303
+ :class="isHourSelected(opt)
304
+ ? 'bg-primary text-primary-foreground'
305
+ : 'text-foreground hover:bg-muted-hover'"
306
+ @click="selectHour(opt)"
307
+ >{{ pad(opt) }}</button>
308
+ </li>
309
+ </ul>
310
+
311
+ <!-- Minutos -->
312
+ <ul class="flex-1 max-h-56 overflow-y-auto pe-1 [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-card-line">
313
+ <li v-for="opt in minuteOptions" :key="'m' + opt">
314
+ <button
315
+ type="button"
316
+ :disabled="isMinuteDisabled(opt)"
317
+ :data-selected="m === opt"
318
+ class="w-full text-center text-sm rounded-control px-2 py-1.5 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
319
+ :class="m === opt
320
+ ? 'bg-primary text-primary-foreground'
321
+ : 'text-foreground hover:bg-muted-hover'"
322
+ @click="selectMinute(opt)"
323
+ >{{ pad(opt) }}</button>
324
+ </li>
325
+ </ul>
326
+
327
+ <!-- Segundos -->
328
+ <ul v-if="seconds" class="flex-1 max-h-56 overflow-y-auto pe-1 [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-card-line">
329
+ <li v-for="opt in secondOptions" :key="'s' + opt">
330
+ <button
331
+ type="button"
332
+ :disabled="isSecondDisabled(opt)"
333
+ :data-selected="s === opt"
334
+ class="w-full text-center text-sm rounded-control px-2 py-1.5 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
335
+ :class="s === opt
336
+ ? 'bg-primary text-primary-foreground'
337
+ : 'text-foreground hover:bg-muted-hover'"
338
+ @click="selectSecond(opt)"
339
+ >{{ pad(opt) }}</button>
340
+ </li>
341
+ </ul>
342
+
343
+ <!-- AM / PM -->
344
+ <ul v-if="hour12" class="w-14 max-h-56 overflow-y-auto">
345
+ <li v-for="p in ['AM', 'PM']" :key="p">
346
+ <button
347
+ type="button"
348
+ :data-selected="period === p && h !== null"
349
+ class="w-full text-center text-sm rounded-control px-2 py-1.5 transition-colors"
350
+ :class="(period === p && h !== null)
351
+ ? 'bg-primary text-primary-foreground'
352
+ : 'text-foreground hover:bg-muted-hover'"
353
+ @click="selectPeriod(p)"
354
+ >{{ p }}</button>
355
+ </li>
356
+ </ul>
357
+ </div>
358
+
359
+ <!-- Footer -->
360
+ <div class="flex items-center justify-between border-t border-card-line mt-2 pt-2">
361
+ <button
362
+ type="button"
363
+ class="text-xs font-medium text-primary hover:underline px-2 py-1"
364
+ @click="setNow"
365
+ >Ahora</button>
366
+ <button
367
+ type="button"
368
+ class="text-xs font-medium text-foreground bg-muted hover:bg-muted-hover rounded-control px-3 py-1 transition-colors"
369
+ @click="close"
370
+ >Aceptar</button>
371
+ </div>
372
+ </div>
373
+ </div>
374
+
375
+ <p v-if="error" class="text-xs text-red-500 dark:text-red-400 mt-1">{{ error }}</p>
376
+ <p v-else-if="hint" class="text-xs text-muted-foreground mt-1">{{ hint }}</p>
377
+ </div>
378
+ </template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@innertia-solutions/innertia-nuxt",
3
- "version": "0.1.41",
3
+ "version": "0.1.42",
4
4
  "description": "Innertia Solutions — Nuxt layer unificada: core, auth, multitenancy, theme y app contexts",
5
5
  "keywords": [
6
6
  "nuxt",