@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.
- package/components/Forms/TimePicker.vue +378 -0
- package/package.json +1 -1
|
@@ -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>
|