@dnax/ui 0.0.48 → 0.0.50

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 (48) hide show
  1. package/components/QBottomSheet.vue +172 -6
  2. package/components/QChart.vue +1 -0
  3. package/components/QDatePicker.vue +1 -1
  4. package/components/QImagePicker.vue +28 -1
  5. package/components/QInputChat.vue +859 -0
  6. package/components/QInputCurrency.vue +319 -0
  7. package/components/QItemLabel.vue +99 -0
  8. package/components/QMap.vue +410 -0
  9. package/components/QNumericKeyboard.vue +304 -0
  10. package/components/QPage.vue +26 -6
  11. package/components/QPageContainer.vue +32 -0
  12. package/components/QPopupProxy.vue +378 -0
  13. package/components/QQrcode.vue +132 -0
  14. package/components/QSelect.vue +12 -0
  15. package/components/QSpreadsheet.vue +395 -69
  16. package/components/QTime.vue +845 -0
  17. package/components/QTimeline.vue +111 -0
  18. package/components/QTimelineEntry.vue +271 -0
  19. package/index.ts +11 -1
  20. package/lib/bottomSheet.test.ts +121 -0
  21. package/lib/bottomSheet.ts +115 -0
  22. package/lib/color.ts +13 -0
  23. package/lib/currency.test.ts +193 -0
  24. package/lib/currency.ts +301 -0
  25. package/lib/fixedLayout.ts +12 -11
  26. package/lib/icons.ts +3 -20
  27. package/lib/map.test.ts +365 -0
  28. package/lib/map.ts +438 -0
  29. package/lib/mapEngine.ts +158 -0
  30. package/lib/mapLeaflet.ts +142 -0
  31. package/lib/mapMaplibre.ts +106 -0
  32. package/lib/mapMaptiler.ts +93 -0
  33. package/lib/numericKeyboard.test.ts +179 -0
  34. package/lib/numericKeyboard.ts +166 -0
  35. package/lib/pagePadding.test.ts +37 -0
  36. package/lib/pagePadding.ts +32 -0
  37. package/lib/qrcode.test.ts +103 -0
  38. package/lib/qrcode.ts +125 -0
  39. package/lib/spreadsheetChanges.test.ts +230 -0
  40. package/lib/spreadsheetChanges.ts +331 -0
  41. package/package.json +5 -17
  42. package/shims.d.ts +19 -0
  43. package/styles/main.css +689 -884
  44. package/components/QTiptap.vue +0 -996
  45. package/components/internal/QTiptapTableView.vue +0 -123
  46. package/components/internal/QTiptapTaskItemView.vue +0 -49
  47. package/lib/tiptap-table.ts +0 -19
  48. package/lib/tiptap-task-list.ts +0 -16
@@ -0,0 +1,845 @@
1
+ <script setup lang="ts">
2
+ // QTime — champ heure borné par `v-model`, API Quasar : <q-time v-model="t" format24h now-btn />
3
+ // La valeur est TOUJOURS une chaîne 24 h : `"HH:MM"` (ou `"HH:MM:SS"` si `withSeconds`).
4
+ // `format24h` ne change que l'AFFICHAGE et la SAISIE (`h:mm AM/PM` en mode 12 h).
5
+ // À l'ouverture, un panneau téléporté (colonnes heures / minutes / secondes) est situé
6
+ // par `placePopover` par rapport au champ, puis suit le scroll et le redimensionnement.
7
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from "vue"
8
+ import { Icon } from "@iconify/vue"
9
+ import { cn } from "../lib/utils"
10
+ import {
11
+ POPOVER_FALLBACK_WIDTH,
12
+ POPOVER_GAP,
13
+ POPOVER_MIN_SPACE,
14
+ POPOVER_VIEWPORT_MARGIN,
15
+ placePopover,
16
+ } from "../lib/datePicker"
17
+
18
+ type TimePosition = "bottom-start" | "bottom-end" | "top-start" | "top-end"
19
+
20
+ interface Props {
21
+ /** Heure liée (`"HH:MM"`, ou `"HH:MM:SS"` si `withSeconds`) */
22
+ modelValue?: string
23
+ /** Affichage 24 h (défaut) ; `false` = saisie et affichage `h:mm AM/PM` */
24
+ format24h?: boolean
25
+ /** Ajoute la colonne des secondes et sérialise en `"HH:MM:SS"` */
26
+ withSeconds?: boolean
27
+ /** Pas des minutes (et des secondes) dans le panneau — défaut 1 */
28
+ minuteStep?: number
29
+ /** Pas des heures dans le panneau — défaut 1 */
30
+ hourStep?: number
31
+ /** Libellé affiché au-dessus du champ */
32
+ label?: string
33
+ /** Texte d'aide affiché sous le champ */
34
+ hint?: string
35
+ /** État d'erreur (bordure + message) */
36
+ error?: boolean
37
+ /** Message affiché quand `error` est vrai */
38
+ errorMessage?: string
39
+ /** Désactive le champ (ni ouverture, ni modification) */
40
+ disable?: boolean
41
+ /** Lecture seule (ni ouverture, ni modification) */
42
+ readonly?: boolean
43
+ /** Champ compact (hauteur réduite) */
44
+ dense?: boolean
45
+ /** Thème sombre explicite (sinon hérité de l'ancêtre `.dark`) */
46
+ dark?: boolean
47
+ /** Bouton « Now » plaçant l'heure courante dans le panneau */
48
+ nowBtn?: boolean
49
+ /** Placement du panneau par rapport au champ — défaut `"bottom-start"` */
50
+ position?: TimePosition
51
+ }
52
+
53
+ const props = withDefaults(defineProps<Props>(), {
54
+ modelValue: "",
55
+ format24h: true,
56
+ withSeconds: false,
57
+ minuteStep: 1,
58
+ hourStep: 1,
59
+ label: "",
60
+ hint: "",
61
+ error: false,
62
+ errorMessage: "",
63
+ disable: false,
64
+ readonly: false,
65
+ dense: false,
66
+ dark: false,
67
+ nowBtn: false,
68
+ position: "bottom-start",
69
+ })
70
+
71
+ const emit = defineEmits<{
72
+ "update:modelValue": [value: string]
73
+ open: []
74
+ close: []
75
+ }>()
76
+
77
+ const inputId = useId()
78
+ const rootEl = ref<HTMLElement | null>(null)
79
+ const inputEl = ref<HTMLInputElement | null>(null)
80
+ const panelRef = ref<HTMLElement | null>(null)
81
+
82
+ const isDisabled = computed(() => props.disable || props.readonly)
83
+
84
+ // ───────────────────────────── Lecture / écriture de la valeur ─────────────────────────────
85
+
86
+ interface TimeParts {
87
+ h: number
88
+ m: number
89
+ s: number
90
+ }
91
+
92
+ /** Résultat brut du parseur : le suffixe meridiem est conservé à part. */
93
+ interface ParsedTime extends TimeParts {
94
+ meridiem: "am" | "pm" | null
95
+ }
96
+
97
+ const pad2 = (n: number) => String(n).padStart(2, "0")
98
+ const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max)
99
+
100
+ /**
101
+ * Analyse tolérante d'une heure : `9:5`, `09:05`, `09h05`, `9h5`, `9.05`, `9 05`,
102
+ * `2:30 PM` (ou `am`/`p.m.`…) sont acceptés. Renvoie `null` si aucun nombre exploitable.
103
+ */
104
+ const parseTime = (raw: string): ParsedTime | null => {
105
+ let text = String(raw ?? "")
106
+ .trim()
107
+ .toLowerCase()
108
+ if (!text) return null
109
+
110
+ let meridiem: "am" | "pm" | null = null
111
+ const mer = text.match(/(a\.?m\.?|p\.?m\.?)\s*$/)
112
+ if (mer) {
113
+ meridiem = mer[1]!.startsWith("p") ? "pm" : "am"
114
+ text = text.slice(0, mer.index).trim()
115
+ }
116
+
117
+ // Sépare par `h`, espace, point, virgule… et retire les séparateurs de fin (`9h`).
118
+ const norm = text.replace(/[h\s.,·]+/g, ":").replace(/:+$/g, "")
119
+ const match = norm.match(/^(\d{1,2})(?::(\d{1,2})(?::(\d{1,2}))?)?$/)
120
+ if (!match) return null
121
+
122
+ return {
123
+ h: Number(match[1]),
124
+ m: match[2] !== undefined ? Number(match[2]) : 0,
125
+ s: match[3] !== undefined ? Number(match[3]) : 0,
126
+ meridiem,
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Borne les composantes et résout le meridiem. La valeur stockée étant toujours en 24 h,
132
+ * un suffixe AM/PM est converti ; en mode 12 h sans suffixe, on reprend le meridiem courant.
133
+ */
134
+ const normalizeParts = (p: ParsedTime, fallbackMeridiem: "am" | "pm"): TimeParts => {
135
+ let h = p.h
136
+ if (props.format24h) {
137
+ if (p.meridiem) h = (h % 12) + (p.meridiem === "pm" ? 12 : 0)
138
+ h = clamp(h, 0, 23)
139
+ } else {
140
+ const meridiem = p.meridiem ?? fallbackMeridiem
141
+ h = (clamp(h, 1, 12) % 12) + (meridiem === "pm" ? 12 : 0)
142
+ }
143
+ return { h, m: clamp(p.m, 0, 59), s: clamp(p.s, 0, 59) }
144
+ }
145
+
146
+ /** Valeur courante (toujours bornée) ; `null` tant qu'aucune heure n'a été saisie. */
147
+ const parsed = computed<TimeParts | null>(() => {
148
+ const p = parseTime(props.modelValue)
149
+ return p ? normalizeParts(p, "am") : null
150
+ })
151
+
152
+ /** Heure de travail : sert d'état pour les colonnes quand aucune valeur n'est posée. */
153
+ const working = computed<TimeParts>(() => parsed.value ?? { h: 0, m: 0, s: 0 })
154
+
155
+ const isPm = computed(() => working.value.h >= 12)
156
+
157
+ const h12 = (h: number) => (h % 12 === 0 ? 12 : h % 12)
158
+ const fromH12 = (hour12: number, pm: boolean) => (hour12 % 12) + (pm ? 12 : 0)
159
+
160
+ /** Sérialise en `"HH:MM"` (ou `"HH:MM:SS"`) — toujours en 24 h. */
161
+ const toModel = (p: TimeParts): string =>
162
+ `${pad2(p.h)}:${pad2(p.m)}${props.withSeconds ? `:${pad2(p.s)}` : ""}`
163
+
164
+ /** Formate pour l'affichage du champ / du panneau (12 h seulement si `format24h` est faux). */
165
+ const formatParts = (p: TimeParts): string => {
166
+ let hour = p.h
167
+ let suffix = ""
168
+ if (!props.format24h) {
169
+ const pm = hour >= 12
170
+ hour = h12(hour)
171
+ suffix = pm ? " PM" : " AM"
172
+ }
173
+ const base = `${props.format24h ? pad2(hour) : hour}:${pad2(p.m)}${props.withSeconds ? `:${pad2(p.s)}` : ""}`
174
+ return base + suffix
175
+ }
176
+
177
+ const displayText = computed(() => (parsed.value ? formatParts(parsed.value) : ""))
178
+ const placeholder = computed(() => (props.withSeconds ? "--:--:--" : "--:--"))
179
+
180
+ // ─────────────────────────────────── Colonnes du panneau ───────────────────────────────────
181
+
182
+ const stepOf = (value: number) => Math.max(1, Math.floor(value) || 1)
183
+ const hourStep = computed(() => stepOf(props.hourStep))
184
+ const minuteStep = computed(() => stepOf(props.minuteStep))
185
+
186
+ const hours = computed<number[]>(() => {
187
+ const out: number[] = []
188
+ if (props.format24h) for (let h = 0; h < 24; h += hourStep.value) out.push(h)
189
+ else for (let h = 1; h <= 12; h += hourStep.value) out.push(h)
190
+ return out
191
+ })
192
+
193
+ const minutes = computed<number[]>(() => {
194
+ const out: number[] = []
195
+ for (let m = 0; m < 60; m += minuteStep.value) out.push(m)
196
+ return out
197
+ })
198
+
199
+ /** Les secondes suivent le pas des minutes (aucune prop `second-step` dans l'API). */
200
+ const seconds = computed<number[]>(() => minutes.value)
201
+
202
+ const hourLabel = (h: number) => (props.format24h ? pad2(h) : String(h))
203
+ const isHourSelected = (h: number) =>
204
+ props.format24h ? working.value.h === h : h12(working.value.h) === h
205
+ const isMinuteSelected = (m: number) => working.value.m === m
206
+ const isSecondSelected = (s: number) => working.value.s === s
207
+
208
+ // ───────────────────────────────────────── Sélection ─────────────────────────────────────────
209
+
210
+ // `h` est TOUJOURS une heure 24 h : les colonnes et les conversions AM/PM ont déjà résolu
211
+ // le meridiem, on ne fait donc que borner avant de sérialiser.
212
+ const commit = (h: number, m: number, s: number) => {
213
+ emit(
214
+ "update:modelValue",
215
+ toModel({ h: clamp(h, 0, 23), m: clamp(m, 0, 59), s: clamp(s, 0, 59) }),
216
+ )
217
+ nextTick(scrollSelectedIntoView)
218
+ }
219
+
220
+ const selectHour = (value: number) => {
221
+ const hour = props.format24h ? value : fromH12(value, isPm.value)
222
+ commit(hour, working.value.m, working.value.s)
223
+ }
224
+ const selectMinute = (value: number) => commit(working.value.h, value, working.value.s)
225
+ const selectSecond = (value: number) => commit(working.value.h, working.value.m, value)
226
+ const setMeridiem = (pm: boolean) =>
227
+ commit(fromH12(h12(working.value.h), pm), working.value.m, working.value.s)
228
+
229
+ const snapTo = (value: number, step: number, max: number) => Math.min(Math.round(value / step) * step, max)
230
+
231
+ const setNow = () => {
232
+ const now = new Date()
233
+ commit(
234
+ snapTo(now.getHours(), hourStep.value, 23),
235
+ snapTo(now.getMinutes(), minuteStep.value, 59),
236
+ snapTo(now.getSeconds(), minuteStep.value, 59),
237
+ )
238
+ }
239
+
240
+ const scrollSelectedIntoView = () => {
241
+ const panel = panelRef.value
242
+ if (!panel) return
243
+ panel.querySelectorAll<HTMLElement>('[aria-selected="true"]').forEach((el) => {
244
+ el.scrollIntoView({ block: "nearest" })
245
+ })
246
+ }
247
+
248
+ // ───────────────────────── Ouverture / fermeture / positionnement ─────────────────────────
249
+
250
+ const open = ref(false)
251
+
252
+ const show = () => {
253
+ if (isDisabled.value || open.value) return
254
+ open.value = true
255
+ }
256
+ const hide = () => {
257
+ if (open.value) open.value = false
258
+ }
259
+ const toggle = () => (open.value ? hide() : show())
260
+
261
+ const onControlClick = () => {
262
+ if (isDisabled.value) return
263
+ show()
264
+ inputEl.value?.focus()
265
+ }
266
+
267
+ const onDocMousedown = (e: MouseEvent) => {
268
+ const target = e.target as Node
269
+ if (rootEl.value?.contains(target) || panelRef.value?.contains(target)) return
270
+ hide()
271
+ }
272
+ const onDocKeydown = (e: KeyboardEvent) => {
273
+ if (e.key === "Escape" && open.value) hide()
274
+ }
275
+
276
+ interface PanelPlacement {
277
+ direction: "down" | "up"
278
+ top: number | null
279
+ bottom: number | null
280
+ left: number
281
+ maxHeight: number
282
+ }
283
+
284
+ /** Espace vertical minimal pour honorer le côté demandé plutôt que la bascule automatique. */
285
+ const MIN_FORCED_SPACE = 120
286
+
287
+ const placement = ref<PanelPlacement | null>(null)
288
+
289
+ /**
290
+ * Mesure le champ puis délègue le calcul à `placePopover` (bascule, écart rogné, hauteur
291
+ * bornée). Le côté demandé par `position` est honoré quand la place le permet, sinon la
292
+ * bascule automatique du helper reprend la main ; le suffixe `-start` / `-end` choisit
293
+ * l'alignement horizontal du panneau. La mesure se fait APRÈS rendu (largeur réelle).
294
+ */
295
+ const positionPanel = () => {
296
+ const el = rootEl.value
297
+ if (!el || typeof window === "undefined") return
298
+
299
+ // Ancre = bas du CHAMP : la zone hint/erreur sous le contrôle ne doit pas éloigner le panneau.
300
+ const control = el.querySelector<HTMLElement>(".q-time__control")
301
+ const hasBottomText = props.error || !!props.hint
302
+ const anchor = (!hasBottomText && control ? control : el).getBoundingClientRect()
303
+ const viewport = { width: window.innerWidth, height: window.innerHeight }
304
+
305
+ const panelWidth = panelRef.value?.getBoundingClientRect().width
306
+ const base = placePopover({
307
+ anchor: { top: anchor.top, bottom: anchor.bottom, left: anchor.left, width: anchor.width },
308
+ viewport,
309
+ panelWidth,
310
+ })
311
+
312
+ const wantsUp = props.position.startsWith("top")
313
+ const spaceAbove = anchor.top - POPOVER_VIEWPORT_MARGIN
314
+ const spaceBelow = viewport.height - anchor.bottom - POPOVER_VIEWPORT_MARGIN
315
+ const room = wantsUp ? spaceAbove : spaceBelow
316
+ const direction: "down" | "up" =
317
+ room >= MIN_FORCED_SPACE ? (wantsUp ? "up" : "down") : base.direction
318
+
319
+ const available = Math.max(direction === "down" ? spaceBelow : spaceAbove, 0)
320
+ const gap = Math.max(0, Math.min(POPOVER_GAP, available - POPOVER_MIN_SPACE))
321
+
322
+ const width = panelWidth ?? POPOVER_FALLBACK_WIDTH
323
+ const alignEnd = props.position.endsWith("end")
324
+ const left = alignEnd
325
+ ? Math.round(
326
+ clamp(anchor.left + anchor.width - width, POPOVER_VIEWPORT_MARGIN, viewport.width - width - POPOVER_VIEWPORT_MARGIN),
327
+ )
328
+ : base.left
329
+
330
+ placement.value = {
331
+ direction,
332
+ top: direction === "down" ? Math.round(anchor.bottom + gap) : null,
333
+ bottom: direction === "up" ? Math.round(viewport.height - anchor.top + gap) : null,
334
+ left,
335
+ maxHeight: Math.round(Math.max(available - gap, 0)),
336
+ }
337
+ }
338
+
339
+ // Le dernier emplacement est conservé à la fermeture : l'animation de sortie en dépend.
340
+ const panelStyle = computed<Record<string, string>>(() => {
341
+ const style: Record<string, string> = {}
342
+ const p = placement.value
343
+ if (!p) {
344
+ style.visibility = "hidden"
345
+ return style
346
+ }
347
+ style.top = p.top === null ? "auto" : `${p.top}px`
348
+ style.bottom = p.bottom === null ? "auto" : `${p.bottom}px`
349
+ style.left = `${p.left}px`
350
+ style.maxHeight = `${p.maxHeight}px`
351
+ return style
352
+ })
353
+
354
+ const panelClasses = computed(() =>
355
+ cn(
356
+ "q-time__panel",
357
+ props.dark && "q-time__panel--dark",
358
+ placement.value?.direction === "up" && "q-time__panel--up",
359
+ ),
360
+ )
361
+
362
+ const onViewportChange = () => {
363
+ if (open.value) positionPanel()
364
+ }
365
+
366
+ watch(open, async (v) => {
367
+ if (v) emit("open")
368
+ else emit("close")
369
+ if (v) {
370
+ await nextTick()
371
+ positionPanel()
372
+ scrollSelectedIntoView()
373
+ }
374
+ })
375
+
376
+ watch(
377
+ () => props.position,
378
+ () => {
379
+ if (open.value) positionPanel()
380
+ },
381
+ )
382
+
383
+ // ───────────────────────────────────── Saisie clavier ─────────────────────────────────────
384
+
385
+ const draft = ref("")
386
+ watch(displayText, (v) => (draft.value = v), { immediate: true })
387
+
388
+ const onInput = (e: Event) => {
389
+ draft.value = (e.target as HTMLInputElement).value
390
+ }
391
+
392
+ /** Valide la saisie : normalise et borne ; une saisie illisible est ignorée (retour). */
393
+ const commitDraft = () => {
394
+ const raw = draft.value.trim()
395
+ const p = raw ? parseTime(raw) : null
396
+ if (!p) {
397
+ draft.value = displayText.value
398
+ return
399
+ }
400
+ const parts = normalizeParts(p, isPm.value ? "pm" : "am")
401
+ emit("update:modelValue", toModel(parts))
402
+ draft.value = formatParts(parts)
403
+ }
404
+
405
+ const onFocus = () => {
406
+ draft.value = displayText.value
407
+ }
408
+ const onBlur = () => commitDraft()
409
+ const onEnter = () => {
410
+ commitDraft()
411
+ hide()
412
+ }
413
+
414
+ /** Flèches ↑/↓ : ± `minuteStep` (panneau fermé), ou ± 1 h avec `Shift`. */
415
+ const onArrow = (direction: 1 | -1, shift: boolean) => {
416
+ if (isDisabled.value || open.value) return
417
+ const step = (shift ? 60 : minuteStep.value) * direction
418
+ const total = working.value.h * 60 + working.value.m
419
+ const next = (((total + step) % 1440) + 1440) % 1440
420
+ commit(Math.floor(next / 60), next % 60, working.value.s)
421
+ }
422
+
423
+ // ────────────────────────────────────────── Cycle de vie ──────────────────────────────────────────
424
+
425
+ onMounted(() => {
426
+ if (typeof document === "undefined") return
427
+ // Phase de CAPTURE : un `@mousedown.stop` parent ne doit pas empêcher la fermeture au clic extérieur.
428
+ document.addEventListener("mousedown", onDocMousedown, true)
429
+ document.addEventListener("keydown", onDocKeydown)
430
+ window.addEventListener("scroll", onViewportChange, true)
431
+ window.addEventListener("resize", onViewportChange)
432
+ })
433
+
434
+ onBeforeUnmount(() => {
435
+ if (typeof document === "undefined") return
436
+ document.removeEventListener("mousedown", onDocMousedown, true)
437
+ document.removeEventListener("keydown", onDocKeydown)
438
+ window.removeEventListener("scroll", onViewportChange, true)
439
+ window.removeEventListener("resize", onViewportChange)
440
+ })
441
+
442
+ const rootClasses = computed(() =>
443
+ cn(
444
+ "q-time",
445
+ props.dark && "q-time--dark",
446
+ props.dense && "q-time--dense",
447
+ props.error && "q-time--error",
448
+ isDisabled.value && "q-time--disabled",
449
+ props.readonly && "q-time--readonly",
450
+ open.value && "q-time--open",
451
+ ),
452
+ )
453
+
454
+ const dialogLabel = computed(() => props.label || "Time")
455
+
456
+ // Forme explicite `clé: valeur` : l'analyse statique des docs détecte les méthodes
457
+ // exposées en lisant les clés de `defineExpose` (cf. `scripts/component-parse.ts`).
458
+ defineExpose({ show: show, hide: hide, toggle: toggle })
459
+ </script>
460
+
461
+ <template>
462
+ <div ref="rootEl" :class="rootClasses" v-bind="$attrs">
463
+ <label v-if="label || $slots.label" class="q-time__label" :for="inputId">
464
+ <slot name="label">{{ label }}</slot>
465
+ </label>
466
+
467
+ <div
468
+ class="q-time__control"
469
+ :class="{ 'q-time__control--open': open }"
470
+ role="group"
471
+ aria-haspopup="dialog"
472
+ :aria-disabled="isDisabled ? 'true' : undefined"
473
+ :aria-expanded="open ? 'true' : 'false'"
474
+ @click="onControlClick"
475
+ >
476
+ <input
477
+ :id="inputId"
478
+ ref="inputEl"
479
+ class="q-time__native"
480
+ type="text"
481
+ inputmode="numeric"
482
+ autocomplete="off"
483
+ spellcheck="false"
484
+ :value="draft"
485
+ :placeholder="placeholder"
486
+ :disabled="disable || undefined"
487
+ :readonly="readonly || undefined"
488
+ :aria-label="label || undefined"
489
+ @input="onInput"
490
+ @focus="onFocus"
491
+ @blur="onBlur"
492
+ @keydown.enter.prevent="onEnter"
493
+ @keydown.up.prevent="onArrow(1, $event.shiftKey)"
494
+ @keydown.down.prevent="onArrow(-1, $event.shiftKey)"
495
+ />
496
+ <Icon icon="lucide:clock" class="q-time__icon" aria-hidden="true" />
497
+ </div>
498
+
499
+ <div class="q-time__bottom">
500
+ <div v-if="error" class="q-time__error">{{ errorMessage }}</div>
501
+ <div v-else-if="hint" class="q-time__hint">{{ hint }}</div>
502
+ </div>
503
+
504
+ <Teleport to="body">
505
+ <Transition name="q-popup">
506
+ <div
507
+ v-if="open"
508
+ ref="panelRef"
509
+ :class="panelClasses"
510
+ :style="panelStyle"
511
+ role="dialog"
512
+ :aria-label="dialogLabel"
513
+ @mousedown.prevent
514
+ >
515
+ <div class="q-time__columns">
516
+ <div class="q-time__column" role="listbox" aria-label="Hour">
517
+ <button
518
+ v-for="h in hours"
519
+ :key="`h-${h}`"
520
+ type="button"
521
+ role="option"
522
+ class="q-time__item"
523
+ :class="{ 'q-time__item--active': isHourSelected(h) }"
524
+ :aria-selected="isHourSelected(h) ? 'true' : 'false'"
525
+ @click="selectHour(h)"
526
+ >
527
+ {{ hourLabel(h) }}
528
+ </button>
529
+ </div>
530
+
531
+ <div class="q-time__column" role="listbox" aria-label="Minute">
532
+ <button
533
+ v-for="m in minutes"
534
+ :key="`m-${m}`"
535
+ type="button"
536
+ role="option"
537
+ class="q-time__item"
538
+ :class="{ 'q-time__item--active': isMinuteSelected(m) }"
539
+ :aria-selected="isMinuteSelected(m) ? 'true' : 'false'"
540
+ @click="selectMinute(m)"
541
+ >
542
+ {{ pad2(m) }}
543
+ </button>
544
+ </div>
545
+
546
+ <div v-if="withSeconds" class="q-time__column" role="listbox" aria-label="Second">
547
+ <button
548
+ v-for="s in seconds"
549
+ :key="`s-${s}`"
550
+ type="button"
551
+ role="option"
552
+ class="q-time__item"
553
+ :class="{ 'q-time__item--active': isSecondSelected(s) }"
554
+ :aria-selected="isSecondSelected(s) ? 'true' : 'false'"
555
+ @click="selectSecond(s)"
556
+ >
557
+ {{ pad2(s) }}
558
+ </button>
559
+ </div>
560
+ </div>
561
+
562
+ <div v-if="nowBtn || !format24h" class="q-time__footer">
563
+ <div v-if="!format24h" class="q-time__meridiem" role="group" aria-label="AM/PM">
564
+ <button
565
+ type="button"
566
+ class="q-time__meridiem-btn"
567
+ :class="{ 'q-time__meridiem-btn--active': !isPm }"
568
+ :aria-pressed="!isPm ? 'true' : 'false'"
569
+ @click="setMeridiem(false)"
570
+ >
571
+ AM
572
+ </button>
573
+ <button
574
+ type="button"
575
+ class="q-time__meridiem-btn"
576
+ :class="{ 'q-time__meridiem-btn--active': isPm }"
577
+ :aria-pressed="isPm ? 'true' : 'false'"
578
+ @click="setMeridiem(true)"
579
+ >
580
+ PM
581
+ </button>
582
+ </div>
583
+ <button v-if="nowBtn" type="button" class="q-time__now" @click="setNow">Now</button>
584
+ </div>
585
+ </div>
586
+ </Transition>
587
+ </Teleport>
588
+ </div>
589
+ </template>
590
+
591
+ <style scoped>
592
+ .q-time {
593
+ position: relative;
594
+ display: flex;
595
+ flex-direction: column;
596
+ min-width: 0;
597
+ font-size: 14px;
598
+ }
599
+
600
+ /* — libellé — */
601
+ .q-time__label {
602
+ display: block;
603
+ margin-bottom: 2px;
604
+ font-size: 12px;
605
+ color: var(--muted-foreground, rgb(0 0 0 / 0.6));
606
+ }
607
+
608
+ /* — contrôle cliquable — */
609
+ .q-time__control {
610
+ position: relative;
611
+ display: flex;
612
+ align-items: center;
613
+ gap: 6px;
614
+ min-height: 40px;
615
+ padding: 0 12px;
616
+ background-color: var(--card, #fff);
617
+ border: 1px solid var(--border, rgb(0 0 0 / 0.22));
618
+ border-radius: var(--radius, var(--q-radius, 4px));
619
+ cursor: pointer;
620
+ transition:
621
+ border-color 0.2s ease,
622
+ box-shadow 0.2s ease,
623
+ background-color 0.2s ease;
624
+ }
625
+ .q-time--dense .q-time__control {
626
+ min-height: 32px;
627
+ }
628
+ .q-time__control:focus-within,
629
+ .q-time__control--open {
630
+ border-color: var(--primary);
631
+ box-shadow: 0 0 0 1px var(--primary);
632
+ }
633
+ .q-time--error .q-time__control {
634
+ border-color: var(--negative);
635
+ }
636
+ .q-time--error .q-time__control:focus-within,
637
+ .q-time--error .q-time__control--open {
638
+ box-shadow: 0 0 0 1px var(--negative);
639
+ }
640
+ .q-time--disabled {
641
+ opacity: 0.5;
642
+ pointer-events: none;
643
+ }
644
+ .q-time--disabled .q-time__control {
645
+ cursor: default;
646
+ }
647
+
648
+ /* — champ de saisie — */
649
+ .q-time__native {
650
+ flex: 1;
651
+ min-width: 0;
652
+ height: 38px;
653
+ padding: 0;
654
+ border: none;
655
+ outline: none;
656
+ background: transparent;
657
+ color: var(--foreground, inherit);
658
+ font: inherit;
659
+ line-height: 20px;
660
+ font-variant-numeric: tabular-nums;
661
+ }
662
+ .q-time--dense .q-time__native {
663
+ height: 30px;
664
+ }
665
+ .q-time__native::placeholder {
666
+ color: var(--muted-foreground, rgb(0 0 0 / 0.45));
667
+ }
668
+
669
+ .q-time__icon {
670
+ flex-shrink: 0;
671
+ width: 16px;
672
+ height: 16px;
673
+ color: var(--muted-foreground, rgb(0 0 0 / 0.45));
674
+ }
675
+
676
+ /* — aide / erreur — */
677
+ .q-time__bottom {
678
+ display: flex;
679
+ align-items: flex-start;
680
+ gap: 8px;
681
+ min-height: 20px;
682
+ padding: 4px 12px 0;
683
+ font-size: 12px;
684
+ line-height: 1.3;
685
+ }
686
+ .q-time__error {
687
+ flex: 1;
688
+ color: var(--negative);
689
+ }
690
+ .q-time__hint {
691
+ flex: 1;
692
+ color: var(--muted-foreground, rgb(0 0 0 / 0.55));
693
+ }
694
+
695
+ /* — panneau (téléporté dans <body>, `position: fixed`) — */
696
+ .q-time__panel {
697
+ position: fixed;
698
+ z-index: var(--q-z-popup, 3500);
699
+ display: flex;
700
+ flex-direction: column;
701
+ min-width: 152px;
702
+ overflow: hidden;
703
+ background-color: var(--card, #fff);
704
+ color: var(--foreground, #1d1d1d);
705
+ border: 1px solid var(--border, rgb(0 0 0 / 0.12));
706
+ border-radius: var(--radius, var(--q-radius, 8px));
707
+ box-shadow: 0 12px 32px rgb(0 0 0 / 0.18);
708
+ }
709
+
710
+ .q-time__columns {
711
+ display: flex;
712
+ flex: 1;
713
+ align-items: stretch;
714
+ min-height: 0;
715
+ overflow: hidden;
716
+ }
717
+ .q-time__column {
718
+ flex: 1;
719
+ min-height: 0;
720
+ max-height: 208px;
721
+ padding: 4px;
722
+ overflow-y: auto;
723
+ scrollbar-width: thin;
724
+ }
725
+ .q-time__column + .q-time__column {
726
+ border-left: 1px solid var(--border, rgb(0 0 0 / 0.08));
727
+ }
728
+ .q-time__item {
729
+ display: block;
730
+ width: 100%;
731
+ min-width: 44px;
732
+ padding: 6px 10px;
733
+ border: none;
734
+ border-radius: calc(var(--radius, var(--q-radius, 8px)) - 4px);
735
+ background: transparent;
736
+ color: inherit;
737
+ font: inherit;
738
+ font-variant-numeric: tabular-nums;
739
+ text-align: center;
740
+ cursor: pointer;
741
+ }
742
+ .q-time__item:hover {
743
+ background-color: var(--muted, rgb(0 0 0 / 0.06));
744
+ }
745
+ .q-time__item--active {
746
+ background-color: var(--primary);
747
+ color: var(--primary-foreground, #fff);
748
+ font-weight: 600;
749
+ }
750
+ .q-time__item--active:hover {
751
+ background-color: var(--primary);
752
+ }
753
+
754
+ /* — pied de panneau : AM/PM et « Now » — */
755
+ .q-time__footer {
756
+ display: flex;
757
+ align-items: center;
758
+ gap: 8px;
759
+ padding: 6px 8px;
760
+ border-top: 1px solid var(--border, rgb(0 0 0 / 0.08));
761
+ }
762
+ .q-time__meridiem {
763
+ display: inline-flex;
764
+ flex: 1;
765
+ padding: 2px;
766
+ background-color: var(--muted, rgb(0 0 0 / 0.05));
767
+ border-radius: calc(var(--radius, var(--q-radius, 8px)) - 4px);
768
+ }
769
+ .q-time__meridiem-btn {
770
+ flex: 1;
771
+ padding: 4px 8px;
772
+ border: none;
773
+ border-radius: calc(var(--radius, var(--q-radius, 8px)) - 6px);
774
+ background: transparent;
775
+ color: inherit;
776
+ font: inherit;
777
+ font-size: 12px;
778
+ cursor: pointer;
779
+ }
780
+ .q-time__meridiem-btn--active {
781
+ background-color: var(--primary);
782
+ color: var(--primary-foreground, #fff);
783
+ font-weight: 600;
784
+ }
785
+ .q-time__now {
786
+ padding: 6px 10px;
787
+ border: none;
788
+ border-radius: calc(var(--radius, var(--q-radius, 8px)) - 4px);
789
+ background: transparent;
790
+ color: var(--primary);
791
+ font: inherit;
792
+ font-size: 12px;
793
+ font-weight: 600;
794
+ cursor: pointer;
795
+ }
796
+ .q-time__now:hover {
797
+ background-color: var(--muted, rgb(0 0 0 / 0.06));
798
+ }
799
+
800
+ /* — mode sombre : ancêtre `.dark` ou prop `dark` (classe `--dark`) — */
801
+ .dark .q-time__label,
802
+ .q-time--dark .q-time__label,
803
+ .dark .q-time__hint,
804
+ .q-time--dark .q-time__hint {
805
+ color: var(--muted-foreground, rgb(255 255 255 / 0.65));
806
+ }
807
+ .dark .q-time__icon,
808
+ .q-time--dark .q-time__icon {
809
+ color: var(--muted-foreground, rgb(255 255 255 / 0.55));
810
+ }
811
+ .dark .q-time__native::placeholder,
812
+ .q-time--dark .q-time__native::placeholder {
813
+ color: rgb(255 255 255 / 0.4);
814
+ }
815
+ .dark .q-time__control,
816
+ .q-time--dark .q-time__control {
817
+ background-color: var(--muted, #1c2128);
818
+ border-color: var(--border);
819
+ color: var(--foreground);
820
+ }
821
+ .dark .q-time__panel,
822
+ .q-time__panel--dark {
823
+ background-color: var(--card);
824
+ color: var(--foreground);
825
+ border-color: var(--border);
826
+ }
827
+ .dark .q-time__item:hover,
828
+ .q-time__panel--dark .q-time__item:hover {
829
+ background-color: rgb(255 255 255 / 0.08);
830
+ }
831
+ .dark .q-time__item--active:hover,
832
+ .q-time__panel--dark .q-time__item--active:hover {
833
+ background-color: var(--primary);
834
+ }
835
+ .dark .q-time__column + .q-time__column,
836
+ .q-time__panel--dark .q-time__column + .q-time__column,
837
+ .dark .q-time__footer,
838
+ .q-time__panel--dark .q-time__footer {
839
+ border-color: var(--border);
840
+ }
841
+ .dark .q-time__meridiem,
842
+ .q-time__panel--dark .q-time__meridiem {
843
+ background-color: rgb(255 255 255 / 0.08);
844
+ }
845
+ </style>