@dnax/ui 0.0.48 → 0.0.49

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 (45) hide show
  1. package/components/QBottomSheet.vue +172 -6
  2. package/components/QImagePicker.vue +28 -1
  3. package/components/QInputChat.vue +859 -0
  4. package/components/QInputCurrency.vue +319 -0
  5. package/components/QItemLabel.vue +99 -0
  6. package/components/QMap.vue +410 -0
  7. package/components/QNumericKeyboard.vue +304 -0
  8. package/components/QPage.vue +26 -6
  9. package/components/QPageContainer.vue +32 -0
  10. package/components/QPopupProxy.vue +378 -0
  11. package/components/QQrcode.vue +132 -0
  12. package/components/QSpreadsheet.vue +395 -69
  13. package/components/QTime.vue +845 -0
  14. package/components/QTimeline.vue +111 -0
  15. package/components/QTimelineEntry.vue +271 -0
  16. package/index.ts +11 -1
  17. package/lib/bottomSheet.test.ts +121 -0
  18. package/lib/bottomSheet.ts +115 -0
  19. package/lib/color.ts +13 -0
  20. package/lib/currency.test.ts +193 -0
  21. package/lib/currency.ts +301 -0
  22. package/lib/fixedLayout.ts +12 -11
  23. package/lib/icons.ts +3 -20
  24. package/lib/map.test.ts +365 -0
  25. package/lib/map.ts +438 -0
  26. package/lib/mapEngine.ts +158 -0
  27. package/lib/mapLeaflet.ts +142 -0
  28. package/lib/mapMaplibre.ts +106 -0
  29. package/lib/mapMaptiler.ts +93 -0
  30. package/lib/numericKeyboard.test.ts +179 -0
  31. package/lib/numericKeyboard.ts +166 -0
  32. package/lib/pagePadding.test.ts +37 -0
  33. package/lib/pagePadding.ts +32 -0
  34. package/lib/qrcode.test.ts +103 -0
  35. package/lib/qrcode.ts +125 -0
  36. package/lib/spreadsheetChanges.test.ts +230 -0
  37. package/lib/spreadsheetChanges.ts +331 -0
  38. package/package.json +5 -17
  39. package/shims.d.ts +19 -0
  40. package/styles/main.css +689 -884
  41. package/components/QTiptap.vue +0 -996
  42. package/components/internal/QTiptapTableView.vue +0 -123
  43. package/components/internal/QTiptapTaskItemView.vue +0 -49
  44. package/lib/tiptap-table.ts +0 -19
  45. package/lib/tiptap-task-list.ts +0 -16
@@ -0,0 +1,859 @@
1
+ <script lang="ts">
2
+ // QInputChat — composer de chat : textarea multiligne auto-extensible, bouton d'envoi
3
+ // intégré, pastilles d'options activables (v-model:options), bouton « + » à menu
4
+ // (actions + pièce jointe) et fichiers joints (v-model:files).
5
+ //
6
+ // Les interfaces `ChatOption` / `ChatAction` vivent dans ce bloc `<script lang="ts">`
7
+ // « normal » (comme `QTimeline`) : elles sont exportées pour les démos / la doc.
8
+
9
+ /** Option activable d'un composer (pastille) — sélection pilotée par `v-model:options` */
10
+ export interface ChatOption {
11
+ /** Libellé de la pastille */
12
+ label: string
13
+ /** Icône Iconify à gauche (ex. "lucide:globe") */
14
+ icon?: string
15
+ /** Valeur métier de l'option */
16
+ value?: any
17
+ /** Identifiant stable (recommandé) — sert à retrouver l'option au clic ; repli sur l'index */
18
+ _id?: string
19
+ /** Sélectionnée (mis à jour par le composant et renvoyé via `v-model:options`) */
20
+ active?: boolean
21
+ /** Désactivée */
22
+ disable?: boolean
23
+ }
24
+
25
+ /**
26
+ * Entrée du menu du bouton « + ». Les champs correspondent à ceux de `BtnAction`
27
+ * de `QBtnActions` : l'objet peut être mappé directement.
28
+ */
29
+ export interface ChatAction {
30
+ /** Libellé de l'entrée de menu */
31
+ label?: string
32
+ /** Icône Iconify à gauche (ex. "lucide:paperclip") */
33
+ icon?: string
34
+ /** Icône Iconify à droite */
35
+ iconRight?: string
36
+ /** Valeur émise par `@action` ; à défaut, l'action elle-même est émise */
37
+ value?: any
38
+ /** Entrée désactivée */
39
+ disable?: boolean
40
+ /** Ligne de séparation affichée au-dessus de l'entrée */
41
+ separator?: boolean
42
+ /** Info-bulle */
43
+ title?: string
44
+ /** Rappel local au clic (en plus de l'événement `action`) */
45
+ onClick?: (action: ChatAction) => void
46
+ }
47
+ </script>
48
+
49
+ <script setup lang="ts">
50
+ import { computed, nextTick, onMounted, ref, useSlots, watch } from "vue"
51
+ import { Icon } from "@iconify/vue"
52
+ import { cn } from "../lib/utils"
53
+ import { icons } from "../lib/icons"
54
+ import { radiusStyle, useRadius } from "../lib/useComponentProps"
55
+ import type { RadiusProp } from "../lib/useComponentProps"
56
+ import QBtn from "./QBtn.vue"
57
+ import QBtnActions, { type BtnAction } from "./QBtnActions.vue"
58
+
59
+ /** Entrée de menu interne : la `BtnAction` porte une référence à son `ChatAction`. */
60
+ interface MenuEntry extends BtnAction {
61
+ __chatAction?: ChatAction
62
+ }
63
+
64
+ /** Valeur sentinelle de l'entrée « joindre un fichier » du menu du « + ». */
65
+ const ATTACH_VALUE = "__dnax-input-chat-attach__"
66
+
67
+ interface Props {
68
+ /** Texte saisi (v-model:message) */
69
+ message?: string
70
+ /** Texte d'invite affiché quand le champ est vide */
71
+ placeholder?: string
72
+ /** Label affiché au-dessus du champ */
73
+ label?: string
74
+ /** Aide affichée sous le champ */
75
+ hint?: string
76
+ /** État d'erreur */
77
+ error?: boolean
78
+ /** Message d'erreur (affiché quand error) */
79
+ errorMessage?: string
80
+ /** Affiche le compteur (nécessite maxlength) */
81
+ counter?: boolean
82
+ /** Longueur maximale du texte */
83
+ maxlength?: number
84
+ /** Hauteur minimale du textarea, en lignes */
85
+ rows?: number
86
+ /** Plafond d'auto-extension, en lignes (0 = pas de plafond) */
87
+ maxRows?: number
88
+ /** Le textarea grandit avec le contenu jusqu'à maxRows, puis défile */
89
+ autogrow?: boolean
90
+ /** Icône Iconify du bouton d'envoi */
91
+ sendIcon?: string
92
+ /** Libellé accessible (aria-label) du bouton d'envoi */
93
+ sendLabel?: string
94
+ /** Couleur du bouton d'envoi : token (primary, secondary…) ou hex */
95
+ sendColor?: string
96
+ /** Entrée envoie le message (Maj+Entrée insère un saut de ligne) */
97
+ sendOnEnter?: boolean
98
+ /** Vide le champ après l'envoi */
99
+ clearOnSend?: boolean
100
+ /** Désactive le bouton d'envoi quand le texte est vide */
101
+ disableSendWhenEmpty?: boolean
102
+ /** Affiche un spinner dans le bouton d'envoi et bloque l'envoi */
103
+ loading?: boolean
104
+ /** Pastilles activables (v-model:options) — la sélection est gérée par le composant */
105
+ options?: ChatOption[]
106
+ /** Entrées du menu du bouton « + » */
107
+ actions?: ChatAction[]
108
+ /** Icône Iconify du bouton « + » */
109
+ actionsIcon?: string
110
+ /** Libellé accessible (aria-label) du bouton « + » */
111
+ actionsLabel?: string
112
+ /** Fichiers joints (v-model:files) */
113
+ files?: File[]
114
+ /** Types de fichiers acceptés (attribut `accept` du sélecteur) */
115
+ accept?: string
116
+ /** Autorise plusieurs fichiers à la fois */
117
+ multiple?: boolean
118
+ /**
119
+ * Entrée « joindre un fichier » dans le menu du « + » : `false` la masque, une chaîne
120
+ * remplace son libellé. Défaut effectif : activée si `accept` est fourni, sinon masquée.
121
+ */
122
+ attach?: boolean | string
123
+ /** Affiche les fichiers choisis en pastilles retirables au-dessus du textarea */
124
+ showFiles?: boolean
125
+ /** Padding du contrôle (valeur CSS) — pour l'aspect aéré des maquettes */
126
+ padding?: string
127
+ /** Padding horizontal (gauche/droite) de la barre d'options (valeur CSS) */
128
+ paddingOptions?: string
129
+ /** Bordure visible */
130
+ outlined?: boolean
131
+ /** Fond gris, soulignement */
132
+ filled?: boolean
133
+ /** Aucune bordure */
134
+ borderless?: boolean
135
+ /** Hauteur réduite */
136
+ dense?: boolean
137
+ /** Coins arrondis : true = pilule, ou échelle xs|sm|md|lg (none = carré) */
138
+ radius?: RadiusProp
139
+ /** Force l'habillage sombre (sinon hérite de l'ancêtre .dark) */
140
+ dark?: boolean
141
+ /** Désactive le champ et le bouton d'envoi */
142
+ disable?: boolean
143
+ /** Champ en lecture seule (le bouton d'envoi est désactivé) */
144
+ readonly?: boolean
145
+ /** Applique la safe-area basse (composer plaqué au bas de l'écran) */
146
+ safeArea?: boolean
147
+ }
148
+
149
+ const props = withDefaults(defineProps<Props>(), {
150
+ error: false,
151
+ counter: false,
152
+ rows: 1,
153
+ maxRows: 6,
154
+ autogrow: true,
155
+ sendIcon: "lucide:send",
156
+ sendLabel: "Send",
157
+ sendColor: "primary",
158
+ sendOnEnter: true,
159
+ clearOnSend: true,
160
+ disableSendWhenEmpty: true,
161
+ loading: false,
162
+ options: () => [],
163
+ actions: () => [],
164
+ actionsIcon: "lucide:plus",
165
+ actionsLabel: "More actions",
166
+ files: () => [],
167
+ multiple: true,
168
+ showFiles: true,
169
+ outlined: false,
170
+ filled: false,
171
+ borderless: false,
172
+ dense: false,
173
+ dark: false,
174
+ disable: false,
175
+ readonly: false,
176
+ safeArea: true,
177
+ })
178
+
179
+ const emit = defineEmits<{
180
+ "update:message": [value: string]
181
+ /** Pastilles mises à jour — **nouveau tableau** (le composant bascule `active`) */
182
+ "update:options": [options: ChatOption[]]
183
+ /** Fichiers joints mis à jour — nouveau tableau */
184
+ "update:files": [files: File[]]
185
+ /** Option basculée (en plus de `update:options`) */
186
+ option: [{ option: ChatOption; active: boolean }]
187
+ send: [value: string]
188
+ clear: []
189
+ /** Entrée du menu du « + » choisie */
190
+ action: [{ action: ChatAction; value: any }]
191
+ focus: [event: FocusEvent]
192
+ blur: [event: FocusEvent]
193
+ }>()
194
+
195
+ const slots = useSlots()
196
+
197
+ const nativeEl = ref<HTMLTextAreaElement | null>(null)
198
+ const fileInput = ref<HTMLInputElement | null>(null)
199
+ // Composition IME en cours (clavier chinois/japonais…) : Entrée ne doit PAS envoyer
200
+ // tant que la composition n'est pas terminée.
201
+ const composing = ref(false)
202
+
203
+ const value = computed(() => props.message ?? "")
204
+
205
+ // — Sélection des options : état interne synchronisé sur la prop (`watch`) — le
206
+ // composant fonctionne avec OU sans `v-model:options`.
207
+ const localOptions = ref<ChatOption[]>([...props.options])
208
+ watch(
209
+ () => props.options,
210
+ (next) => {
211
+ localOptions.value = [...next]
212
+ },
213
+ )
214
+
215
+ // — Fichiers joints : même principe (état interne synchronisé sur la prop).
216
+ const localFiles = ref<File[]>([...props.files])
217
+ watch(
218
+ () => props.files,
219
+ (next) => {
220
+ localFiles.value = [...next]
221
+ },
222
+ )
223
+
224
+ // radius : prop explicite > composantProps.QInputChat.radius ; échelle → --q-radius
225
+ const effectiveRadius = useRadius("QInputChat", () => props.radius)
226
+ const roundedStyle = computed(() => radiusStyle(effectiveRadius.value))
227
+
228
+ const fieldClasses = computed(() =>
229
+ cn(
230
+ "q-input-chat",
231
+ props.outlined && "q-field--outlined",
232
+ props.filled && "q-field--filled",
233
+ props.borderless && "q-field--borderless",
234
+ effectiveRadius.value === true && "q-field--rounded",
235
+ props.dense && "q-field--dense",
236
+ props.error && "q-field--error",
237
+ props.disable && "q-field--disabled",
238
+ props.dark && "q-input-chat--dark",
239
+ props.safeArea && "q-input-chat--safe-area",
240
+ // `padding` fourni → la hauteur du champ est réglée par le contrôle (plein
241
+ // composeur) : le textarea garde la hauteur de champ standard.
242
+ props.padding && "q-input-chat--custom-padding",
243
+ ),
244
+ )
245
+
246
+ // `padding` : valeur CSS posée en variable, lue par la règle du contrôle.
247
+ const controlStyle = computed<Record<string, string> | undefined>(() =>
248
+ props.padding ? { "--q-input-chat-padding": props.padding } : undefined,
249
+ )
250
+
251
+ // `padding-options` : valeur CSS posée en variable, lue en padding horizontal de la barre.
252
+ const optionsStyle = computed<Record<string, string> | undefined>(() =>
253
+ props.paddingOptions ? { "--q-input-chat-options-padding": props.paddingOptions } : undefined,
254
+ )
255
+
256
+ // — Pièce jointe : activée si `accept` fourni (ou `attach` explicite) —
257
+ const attachEnabled = computed(() =>
258
+ props.attach === undefined ? !!props.accept : props.attach !== false,
259
+ )
260
+ const attachLabel = computed(() => (typeof props.attach === "string" ? props.attach : "Add file"))
261
+
262
+ // — Menu du « + » : entrée attach (si activée) + actions mappées en `BtnAction` —
263
+ const menuActions = computed<MenuEntry[]>(() => {
264
+ const list: MenuEntry[] = []
265
+ if (attachEnabled.value) {
266
+ list.push({ label: attachLabel.value, icon: "lucide:paperclip", value: ATTACH_VALUE })
267
+ }
268
+ for (const action of props.actions) list.push({ ...action, __chatAction: action })
269
+ return list
270
+ })
271
+
272
+ const showToolbar = computed(
273
+ () =>
274
+ localOptions.value.length > 0 ||
275
+ !!slots.options ||
276
+ props.actions.length > 0 ||
277
+ !!slots.actions ||
278
+ !!slots.tools ||
279
+ attachEnabled.value,
280
+ )
281
+
282
+ // Séparateur « | » entre les pastilles d'options et le bouton « + » (rendu seulement
283
+ // quand les deux zones sont présentes, sinon la barre ne montrerait qu'un trait isolé).
284
+ const showOptionsSeparator = computed(
285
+ () =>
286
+ (localOptions.value.length > 0 || !!slots.options) &&
287
+ (menuActions.value.length > 0 || !!slots.actions),
288
+ )
289
+
290
+ // — Sélection d'une option : bascule dans l'état interne, émet le NOUVEAU tableau —
291
+ const toggleOption = (option: ChatOption, index: number) => {
292
+ if (option.disable) return
293
+ const same = (o: ChatOption, i: number) =>
294
+ option._id !== undefined ? o._id === option._id : i === index
295
+ const next = localOptions.value.map((o, i) => (same(o, i) ? { ...o, active: !o.active } : o))
296
+ localOptions.value = next
297
+ const changed = next.find((o, i) => same(o, i))
298
+ emit("update:options", next)
299
+ if (changed) emit("option", { option: changed, active: !!changed.active })
300
+ }
301
+
302
+ // — Pièce jointe : sélecteur de fichiers —
303
+ const openFilePicker = () => fileInput.value?.click()
304
+
305
+ const fileKey = (f: File) => `${f.name}:${f.size}:${f.lastModified}`
306
+
307
+ const onFilesChange = (e: Event) => {
308
+ const input = e.target as HTMLInputElement
309
+ const picked = Array.from(input.files ?? [])
310
+ // Remet à zéro pour pouvoir re-choisir le même fichier.
311
+ input.value = ""
312
+ if (!picked.length) return
313
+ const seen = new Set(localFiles.value.map(fileKey))
314
+ const next = [...localFiles.value]
315
+ for (const f of picked) {
316
+ const key = fileKey(f)
317
+ if (seen.has(key)) continue
318
+ seen.add(key)
319
+ next.push(f)
320
+ }
321
+ localFiles.value = next
322
+ emit("update:files", next)
323
+ }
324
+
325
+ const removeFile = (index: number) => {
326
+ const next = localFiles.value.filter((_, i) => i !== index)
327
+ localFiles.value = next
328
+ emit("update:files", next)
329
+ }
330
+
331
+ const formatSize = (bytes: number): string => {
332
+ if (bytes < 1024) return `${bytes} B`
333
+ if (bytes < 1024 * 1024) {
334
+ const ko = bytes / 1024
335
+ return `${ko < 10 ? ko.toFixed(1) : Math.round(ko)} Ko`
336
+ }
337
+ return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
338
+ }
339
+
340
+ // — Entrée du menu du « + » choisie —
341
+ const onSelectAction = (value: unknown) => {
342
+ if (value === ATTACH_VALUE) {
343
+ openFilePicker()
344
+ return
345
+ }
346
+ // QBtnActions renvoie `value` de l'entrée, sinon l'entrée elle-même (qui porte la ref).
347
+ const entry: MenuEntry | undefined =
348
+ value && typeof value === "object" && "__chatAction" in value
349
+ ? (value as MenuEntry)
350
+ : menuActions.value.find((e) => e.value !== undefined && e.value === value)
351
+ const action = entry?.__chatAction
352
+ if (!action) return
353
+ action.onClick?.(action)
354
+ emit("action", { action, value: action.value ?? action })
355
+ }
356
+
357
+ // — Auto-extension : mesure scrollHeight (hauteur remise à `auto` avant) plafonnée à
358
+ // `maxRows` lignes (line-height réelle mesurée), puis overflow-y auto. Même mécanique
359
+ // que `resizeTextarea` de QInput.vue.
360
+ const resizeTextarea = async () => {
361
+ const el = nativeEl.value
362
+ if (!el || !props.autogrow) return
363
+ await nextTick()
364
+ el.style.height = "auto"
365
+ const cs = getComputedStyle(el)
366
+ const fontSize = Number.parseFloat(cs.fontSize) || 14
367
+ const lineHeight = Number.parseFloat(cs.lineHeight) || fontSize * 1.4
368
+ const padding = (Number.parseFloat(cs.paddingTop) || 0) + (Number.parseFloat(cs.paddingBottom) || 0)
369
+ const cap = props.maxRows > 0 ? lineHeight * props.maxRows + padding : Number.POSITIVE_INFINITY
370
+ const full = el.scrollHeight
371
+ el.style.height = `${Math.min(full, cap)}px`
372
+ el.style.overflowY = full > cap ? "auto" : "hidden"
373
+ }
374
+
375
+ watch([value, () => props.autogrow, () => props.maxRows], resizeTextarea)
376
+ onMounted(resizeTextarea)
377
+
378
+ // — Envoi —
379
+ const canSend = computed(() => !props.disable && !props.readonly && !props.loading)
380
+ const sendDisabled = computed(
381
+ () => !canSend.value || (props.disableSendWhenEmpty && value.value.trim() === ""),
382
+ )
383
+
384
+ const focus = () => nativeEl.value?.focus()
385
+ const blur = () => nativeEl.value?.blur()
386
+
387
+ const send = () => {
388
+ if (!canSend.value) return
389
+ // Valeur vide (après trim) : rien n'est émis. Le texte émis est **trimé**.
390
+ const text = value.value.trim()
391
+ if (!text) return
392
+ emit("send", text)
393
+ if (props.clearOnSend) {
394
+ emit("update:message", "")
395
+ emit("clear")
396
+ }
397
+ // Textarea remis à sa hauteur de base (contenu vidé) + focus rendu à la saisie.
398
+ nextTick(() => {
399
+ resizeTextarea()
400
+ focus()
401
+ })
402
+ }
403
+
404
+ const clear = () => {
405
+ emit("update:message", "")
406
+ emit("clear")
407
+ nextTick(() => {
408
+ resizeTextarea()
409
+ focus()
410
+ })
411
+ }
412
+
413
+ // — Saisie & clavier —
414
+ const onInput = (e: Event) => {
415
+ emit("update:message", (e.target as HTMLTextAreaElement).value)
416
+ }
417
+
418
+ const onKeydown = (e: KeyboardEvent) => {
419
+ if (e.key !== "Enter" || !props.sendOnEnter) return
420
+ // Maj/Alt/Ctrl/Meta + Entrée → comportement natif (saut de ligne).
421
+ if (e.shiftKey || e.altKey || e.ctrlKey || e.metaKey) return
422
+ // Jamais d'envoi pendant une composition IME (`isComposing` en complément du flag).
423
+ if (e.isComposing || composing.value) return
424
+ e.preventDefault()
425
+ send()
426
+ }
427
+
428
+ const onCompositionStart = () => {
429
+ composing.value = true
430
+ }
431
+
432
+ const onCompositionEnd = () => {
433
+ composing.value = false
434
+ }
435
+
436
+ const onFocus = (e: FocusEvent) => emit("focus", e)
437
+
438
+ const onBlur = (e: FocusEvent) => {
439
+ composing.value = false
440
+ emit("blur", e)
441
+ }
442
+
443
+ const inputAttrs = computed(() => ({
444
+ maxlength: props.maxlength,
445
+ placeholder: props.placeholder,
446
+ disabled: props.disable || undefined,
447
+ readonly: props.readonly || undefined,
448
+ "aria-label": props.label ?? props.placeholder,
449
+ }))
450
+
451
+ // Forme explicite `clé: valeur` : l'analyse statique des docs détecte les méthodes
452
+ // exposées en lisant les clés de `defineExpose` (cf. `scripts/component-parse.ts`).
453
+ defineExpose({ focus: focus, blur: blur, clear: clear, send: send })
454
+ </script>
455
+
456
+ <template>
457
+ <div class="q-input-chat" :class="fieldClasses" :style="roundedStyle" v-bind="$attrs">
458
+ <label v-if="label" class="q-field__label-stack">{{ label }}</label>
459
+
460
+ <!-- Fichiers joints : pastilles retirables au-dessus du textarea -->
461
+ <div v-if="showFiles && localFiles.length" class="q-input-chat__files">
462
+ <slot name="files" :files="localFiles" :remove="removeFile">
463
+ <span v-for="(f, i) in localFiles" :key="`${f.name}-${i}`" class="q-input-chat__file">
464
+ <Icon :icon="icons.file" class="q-input-chat__file-icon" aria-hidden="true" />
465
+ <span class="q-input-chat__file-name">{{ f.name }}</span>
466
+ <span class="q-input-chat__file-size">{{ formatSize(f.size) }}</span>
467
+ <button
468
+ type="button"
469
+ class="q-input-chat__file-remove"
470
+ :aria-label="`Remove ${f.name}`"
471
+ @click="removeFile(i)"
472
+ >
473
+ <Icon :icon="icons.x" aria-hidden="true" />
474
+ </button>
475
+ </span>
476
+ </slot>
477
+ </div>
478
+
479
+ <div class="q-field__control q-input-chat__control" :style="controlStyle">
480
+ <slot name="prepend" />
481
+ <div class="q-input-chat__textarea-wrap">
482
+ <textarea
483
+ ref="nativeEl"
484
+ class="q-field__native q-input-chat__native"
485
+ :value="value"
486
+ :rows="rows"
487
+ v-bind="inputAttrs"
488
+ @input="onInput"
489
+ @keydown="onKeydown"
490
+ @compositionstart="onCompositionStart"
491
+ @compositionend="onCompositionEnd"
492
+ @focus="onFocus"
493
+ @blur="onBlur"
494
+ />
495
+ </div>
496
+ <slot name="append" />
497
+ <!-- Envoyer « inline » quand il n'y a pas de barre (une seule ligne) -->
498
+ <slot v-if="!showToolbar" name="send" :send="send" :disabled="sendDisabled" :loading="loading">
499
+ <q-btn
500
+ class="q-input-chat__send"
501
+ type="button"
502
+ round
503
+ :icon="sendIcon"
504
+ :color="sendColor"
505
+ :loading="loading"
506
+ :disable="sendDisabled"
507
+ :aria-label="sendLabel"
508
+ @click="send"
509
+ />
510
+ </slot>
511
+ </div>
512
+
513
+ <div v-if="showToolbar" class="q-input-chat__toolbar" :style="optionsStyle">
514
+ <div class="q-input-chat__options">
515
+ <slot name="options" :options="localOptions" :toggle="toggleOption">
516
+ <q-btn
517
+ v-for="(o, i) in localOptions"
518
+ :key="o._id ?? i"
519
+ class="q-input-chat__option"
520
+ :class="{ 'q-input-chat__option--active': o.active }"
521
+ outline
522
+ dense
523
+ no-caps
524
+ radius
525
+ :icon="o.icon"
526
+ :label="o.label"
527
+ :disable="o.disable"
528
+ @click="toggleOption(o, i)"
529
+ />
530
+ </slot>
531
+ </div>
532
+
533
+ <span v-if="showOptionsSeparator" class="q-input-chat__toolbar-separator" aria-hidden="true" />
534
+
535
+ <div v-if="$slots.actions || menuActions.length" class="q-input-chat__actions">
536
+ <slot name="actions">
537
+ <q-btn-actions
538
+ class="q-input-chat__add"
539
+ flat
540
+ dense
541
+ round
542
+ no-caps
543
+ :icon="actionsIcon"
544
+ :aria-label="actionsLabel"
545
+ :actions="menuActions"
546
+ dropdown-icon="lucide:chevron-down"
547
+ @select-action="onSelectAction"
548
+ />
549
+ </slot>
550
+ </div>
551
+
552
+ <div class="q-input-chat__spacer" />
553
+ <div class="q-input-chat__tools">
554
+ <slot name="tools" />
555
+ <slot name="send" :send="send" :disabled="sendDisabled" :loading="loading">
556
+ <q-btn
557
+ class="q-input-chat__send"
558
+ type="button"
559
+ round
560
+ :icon="sendIcon"
561
+ :color="sendColor"
562
+ :loading="loading"
563
+ :disable="sendDisabled"
564
+ :aria-label="sendLabel"
565
+ @click="send"
566
+ />
567
+ </slot>
568
+ </div>
569
+ </div>
570
+
571
+ <input
572
+ ref="fileInput"
573
+ type="file"
574
+ class="q-input-chat__file-input"
575
+ :accept="accept"
576
+ :multiple="multiple"
577
+ hidden
578
+ @change="onFilesChange"
579
+ />
580
+
581
+ <div class="q-field__bottom">
582
+ <div v-if="error" class="q-field__error">
583
+ <slot name="error">{{ errorMessage }}</slot>
584
+ </div>
585
+ <div v-else-if="hint || $slots.hint" class="q-field__hint">
586
+ <slot name="hint">{{ hint }}</slot>
587
+ </div>
588
+ <div v-if="counter && maxlength" class="q-field__counter">
589
+ {{ value.length }}/{{ maxlength }}
590
+ </div>
591
+ </div>
592
+ </div>
593
+ </template>
594
+
595
+ <style scoped>
596
+ /* Racine = colonne, comme `.q-input` (les règles globales de champ sont scopées à
597
+ `.q-input`, jamais à cette racine). */
598
+ .q-input-chat {
599
+ display: flex;
600
+ flex-direction: column;
601
+ min-width: 0;
602
+ font-size: 14px;
603
+
604
+ /* Hauteur d'une ligne du textarea. Un composer de chat s'assied un peu plus haut
605
+ qu'un champ simple (40px) ; surchargée par les modifiers ci-dessous. */
606
+ --q-input-chat-field-height: 44px;
607
+ --q-input-chat-field-padding: 12px;
608
+ }
609
+
610
+ /* `padding` custom (plein composeur) : le contrôle porte la hauteur, on rend au
611
+ textarea la hauteur de champ standard pour ne pas décaler la maquette. */
612
+ .q-input-chat.q-input-chat--custom-padding {
613
+ --q-input-chat-field-height: 38px;
614
+ --q-input-chat-field-padding: 9px;
615
+ }
616
+
617
+ /* Variante dense : une ligne resserrée (accorde le contrôle à 32px). */
618
+ .q-input-chat.q-field--dense {
619
+ --q-input-chat-field-height: 32px;
620
+ --q-input-chat-field-padding: 6px;
621
+ }
622
+
623
+ /* Padding du contrôle : valeur par défaut, surchargée par la prop `padding`
624
+ (`--q-input-chat-padding`). `align-items: center` centre `#prepend`/`#append` dans
625
+ la ligne (le textarea la remplit, l'envoi inline s'ancre en haut via `align-self`).
626
+ Spécificité montée (2 classes) pour battre `.q-field__control`. */
627
+ .q-field__control.q-input-chat__control {
628
+ align-items: center;
629
+ padding: var(--q-input-chat-padding, 0 12px);
630
+ }
631
+
632
+ /* Enveloppe du textarea : prend la largeur restante (prépare les piliers gauche/droite). */
633
+ .q-input-chat__textarea-wrap {
634
+ display: flex;
635
+ flex: 1;
636
+ min-width: 0;
637
+ align-items: flex-end;
638
+ }
639
+
640
+ /* Champ natif — recopie du nécessaire de `.q-input .q-field__native`. */
641
+ .q-input-chat__native {
642
+ flex: 1;
643
+ min-width: 0;
644
+ height: auto;
645
+ min-height: var(--q-input-chat-field-height);
646
+ padding: var(--q-input-chat-field-padding) 0;
647
+ border: none;
648
+ outline: none;
649
+ background: transparent;
650
+ color: var(--foreground);
651
+ font: inherit;
652
+ line-height: 20px;
653
+ resize: none;
654
+ overflow-y: auto;
655
+ }
656
+
657
+ .q-input-chat__native::placeholder {
658
+ color: var(--muted-foreground);
659
+ }
660
+
661
+ /* Envoi : 30px dans les deux cas (même échelle que les pastilles d'options et les
662
+ outils de la barre), sinon le `round` de 36px par défaut écrase la rangée. Sélecteurs
663
+ à 2 classes pour battre `--q-btn-h: 36px` / `font-size: 14px` déclarés sur l'élément
664
+ par `.q-btn` / `.q-btn--md`. */
665
+ .q-input-chat__control .q-input-chat__send,
666
+ .q-input-chat__tools .q-input-chat__send {
667
+ flex: none;
668
+ --q-btn-h: 30px;
669
+ font-size: 12px;
670
+ }
671
+
672
+ /* Envoi « inline » (pas de barre) : ancré **en haut** — le `margin-top` vaut la
673
+ demi-différence avec la ligne (hauteur de champ − bouton 30px − 2px de bordures),
674
+ donc centré sur la première ligne et **en haut à droite** quand le textarea grandit
675
+ (autogrow). Dans la barre, le bouton est centré par `.q-input-chat__tools`
676
+ (`align-items: center`), sans marge. */
677
+ .q-input-chat__control .q-input-chat__send {
678
+ align-self: flex-start;
679
+ margin-top: calc((var(--q-input-chat-field-height) - 32px) / 2);
680
+ }
681
+
682
+ /* ===== Fichiers joints ===== */
683
+ .q-input-chat__files {
684
+ display: flex;
685
+ flex-wrap: wrap;
686
+ gap: 6px;
687
+ padding: 0 12px 6px;
688
+ }
689
+
690
+ .q-input-chat__file {
691
+ display: inline-flex;
692
+ align-items: center;
693
+ gap: 6px;
694
+ max-width: 100%;
695
+ padding: 4px 6px 4px 8px;
696
+ border: 1px solid var(--border);
697
+ border-radius: 999px;
698
+ background-color: color-mix(in srgb, var(--foreground) 4%, transparent);
699
+ font-size: 12px;
700
+ color: var(--muted-foreground);
701
+ }
702
+
703
+ .q-input-chat__file-icon {
704
+ width: 14px;
705
+ height: 14px;
706
+ flex-shrink: 0;
707
+ }
708
+
709
+ .q-input-chat__file-name {
710
+ max-width: 220px;
711
+ overflow: hidden;
712
+ white-space: nowrap;
713
+ text-overflow: ellipsis;
714
+ color: var(--foreground);
715
+ }
716
+
717
+ .q-input-chat__file-size {
718
+ flex-shrink: 0;
719
+ opacity: 0.8;
720
+ }
721
+
722
+ .q-input-chat__file-remove {
723
+ display: inline-flex;
724
+ align-items: center;
725
+ justify-content: center;
726
+ width: 16px;
727
+ height: 16px;
728
+ padding: 0;
729
+ border: none;
730
+ border-radius: 50%;
731
+ background: transparent;
732
+ color: inherit;
733
+ cursor: pointer;
734
+ }
735
+
736
+ .q-input-chat__file-remove:hover {
737
+ background-color: color-mix(in srgb, var(--foreground) 10%, transparent);
738
+ }
739
+
740
+ .q-input-chat__file-remove svg {
741
+ width: 12px;
742
+ height: 12px;
743
+ }
744
+
745
+ /* ===== Barre (options + « + » à gauche, outils + envoi à droite) ===== */
746
+ .q-input-chat__toolbar {
747
+ display: flex;
748
+ flex-wrap: wrap;
749
+ align-items: center;
750
+ gap: 0;
751
+ padding-top: 6px;
752
+ padding-bottom: 10px;
753
+ /* Padding horizontal (gauche/droite) piloté par la prop `padding-options`. */
754
+ padding-inline: var(--q-input-chat-options-padding, 0);
755
+ }
756
+
757
+ .q-input-chat__options,
758
+ .q-input-chat__actions,
759
+ .q-input-chat__tools {
760
+ display: flex;
761
+ flex-wrap: wrap;
762
+ align-items: center;
763
+ gap: 6px;
764
+ min-width: 0;
765
+ }
766
+
767
+ /* Séparateur « | » entre les pastilles et le bouton « + ». */
768
+ .q-input-chat__toolbar-separator {
769
+ align-self: center;
770
+ width: 1px;
771
+ height: 16px;
772
+ margin: 0 8px;
773
+ background-color: var(--border);
774
+ }
775
+
776
+ .q-input-chat__spacer {
777
+ flex: 1 1 auto;
778
+ }
779
+
780
+ .q-input-chat__tools {
781
+ margin-left: auto;
782
+ }
783
+
784
+ /* Pastille d'option : bordure 1px, rayon pilule, libellé ~13px. */
785
+ .q-input-chat__option {
786
+ --q-btn-bg: var(--muted-foreground);
787
+ --q-btn-h: 30px;
788
+ padding: 0 10px;
789
+ gap: 6px;
790
+ border-color: var(--border);
791
+ color: var(--muted-foreground);
792
+ font-size: 13px;
793
+ font-weight: 500;
794
+ text-transform: none;
795
+ letter-spacing: 0;
796
+ box-shadow: none;
797
+ }
798
+
799
+ .q-input-chat__option:hover {
800
+ border-color: var(--muted-foreground);
801
+ background-color: color-mix(in srgb, var(--foreground) 6%, transparent);
802
+ }
803
+
804
+ /* Option active : bordure + texte primaires, léger fond primaire. */
805
+ .q-input-chat__option.q-input-chat__option--active {
806
+ --q-btn-bg: var(--primary);
807
+ border-color: var(--primary);
808
+ color: var(--primary);
809
+ background-color: color-mix(in srgb, var(--primary) 12%, transparent);
810
+ }
811
+
812
+ .q-input-chat__option.q-input-chat__option--active:hover {
813
+ background-color: color-mix(in srgb, var(--primary) 18%, transparent);
814
+ }
815
+
816
+ /* Bouton « + » (trigger interne de QBtnActions) : icône grise, rond. */
817
+ .q-input-chat__add :deep(.q-btn-actions__trigger) {
818
+ --q-btn-bg: var(--muted-foreground);
819
+ --q-btn-h: 30px;
820
+ color: var(--muted-foreground);
821
+ box-shadow: none;
822
+ }
823
+
824
+ /* ===== Mode sombre ===== */
825
+ /* Champ : ancêtre `.dark` (héritage) ou prop `dark` (classe `--dark`). */
826
+ .dark .q-input-chat__native,
827
+ .q-input-chat--dark .q-input-chat__native {
828
+ color: var(--foreground);
829
+ }
830
+
831
+ .dark .q-input-chat__native::placeholder,
832
+ .q-input-chat--dark .q-input-chat__native::placeholder {
833
+ color: var(--muted-foreground);
834
+ }
835
+
836
+ .dark .q-input-chat__option:hover,
837
+ .q-input-chat--dark .q-input-chat__option:hover {
838
+ background-color: rgb(255 255 255 / 0.06);
839
+ }
840
+
841
+ .dark .q-input-chat__add :deep(.q-btn-actions__trigger:hover),
842
+ .q-input-chat--dark .q-input-chat__add :deep(.q-btn-actions__trigger:hover) {
843
+ background-color: rgb(255 255 255 / 0.08);
844
+ }
845
+
846
+ .dark .q-input-chat__file,
847
+ .q-input-chat--dark .q-input-chat__file {
848
+ border-color: var(--border);
849
+ background-color: rgb(255 255 255 / 0.04);
850
+ }
851
+
852
+ /* Safe-area basse (règle projet obligatoire) : un composer de chat est plaqué au bas
853
+ de l'écran. Chaîne de fallback en 3 niveaux, dans cet ordre. */
854
+ .q-input-chat--safe-area {
855
+ padding-bottom: 0; /* fallback vieux navigateurs */
856
+ padding-bottom: constant(safe-area-inset-bottom); /* iOS 11.0 – 11.2 */
857
+ padding-bottom: env(safe-area-inset-bottom); /* iOS 11.2+ */
858
+ }
859
+ </style>