@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.
- package/components/QBottomSheet.vue +172 -6
- package/components/QChart.vue +1 -0
- package/components/QDatePicker.vue +1 -1
- package/components/QImagePicker.vue +28 -1
- package/components/QInputChat.vue +859 -0
- package/components/QInputCurrency.vue +319 -0
- package/components/QItemLabel.vue +99 -0
- package/components/QMap.vue +410 -0
- package/components/QNumericKeyboard.vue +304 -0
- package/components/QPage.vue +26 -6
- package/components/QPageContainer.vue +32 -0
- package/components/QPopupProxy.vue +378 -0
- package/components/QQrcode.vue +132 -0
- package/components/QSelect.vue +12 -0
- package/components/QSpreadsheet.vue +395 -69
- package/components/QTime.vue +845 -0
- package/components/QTimeline.vue +111 -0
- package/components/QTimelineEntry.vue +271 -0
- package/index.ts +11 -1
- package/lib/bottomSheet.test.ts +121 -0
- package/lib/bottomSheet.ts +115 -0
- package/lib/color.ts +13 -0
- package/lib/currency.test.ts +193 -0
- package/lib/currency.ts +301 -0
- package/lib/fixedLayout.ts +12 -11
- package/lib/icons.ts +3 -20
- package/lib/map.test.ts +365 -0
- package/lib/map.ts +438 -0
- package/lib/mapEngine.ts +158 -0
- package/lib/mapLeaflet.ts +142 -0
- package/lib/mapMaplibre.ts +106 -0
- package/lib/mapMaptiler.ts +93 -0
- package/lib/numericKeyboard.test.ts +179 -0
- package/lib/numericKeyboard.ts +166 -0
- package/lib/pagePadding.test.ts +37 -0
- package/lib/pagePadding.ts +32 -0
- package/lib/qrcode.test.ts +103 -0
- package/lib/qrcode.ts +125 -0
- package/lib/spreadsheetChanges.test.ts +230 -0
- package/lib/spreadsheetChanges.ts +331 -0
- package/package.json +5 -17
- package/shims.d.ts +19 -0
- package/styles/main.css +689 -884
- package/components/QTiptap.vue +0 -996
- package/components/internal/QTiptapTableView.vue +0 -123
- package/components/internal/QTiptapTaskItemView.vue +0 -49
- package/lib/tiptap-table.ts +0 -19
- 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>
|