@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
@@ -2,6 +2,11 @@
2
2
  // QBottomSheet — bottom sheet type Drawer shadcn-vue (vaul), style Quasar :
3
3
  // <q-bottom-sheet v-model="open"> + slot trigger / QBottomSheetHeader / QBottomSheetFooter
4
4
  // Drag vers le bas pour fermer, backdrop, Échap, safe-area bottom.
5
+ //
6
+ // Points d'ancrage (style Ionic) : `:breakpoints="[0.25, 0.5, 0.75]"` pose le panneau sur
7
+ // des fractions de la hauteur de vue, la poignée (ou le clavier) passant de l'une à
8
+ // l'autre. Le contenu reste **défilable à chaque point d'ancrage** : c'est la poignée qui
9
+ // pilote la hauteur, jamais le scroll.
5
10
  import type { InjectionKey, Ref } from "vue"
6
11
 
7
12
  export interface BottomSheetContext {
@@ -14,6 +19,14 @@ export const qBottomSheetKey: InjectionKey<BottomSheetContext> = Symbol("q-botto
14
19
 
15
20
  <script setup lang="ts">
16
21
  import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from "vue"
22
+ import {
23
+ clampRatio,
24
+ nearestBreakpoint,
25
+ normalizeBreakpoints,
26
+ ratioFromDrag,
27
+ releaseBreakpoint,
28
+ stepBreakpoint,
29
+ } from "../lib/bottomSheet"
17
30
  import { cn } from "../lib/utils"
18
31
  import { useOverlayBack } from "../lib/overlayBack"
19
32
  import { markOverlayClose } from "../lib/closeOverlay"
@@ -23,15 +36,27 @@ interface Props {
23
36
  modelValue?: boolean
24
37
  /** Ne se ferme ni au backdrop ni à Échap */
25
38
  persistent?: boolean
39
+ /** **Sans backdrop** : le fond n'est pas assombri et reste **interactif** (les clics le
40
+ * traversent) — le clic à côté ne ferme donc plus le panneau. Échap, bouton « retour »
41
+ * et `v-model` restent les moyens de le fermer (voir aussi `persistent`). */
42
+ seamless?: boolean
26
43
  /** Largeur max du panneau */
27
44
  width?: string
28
45
  /** Hauteur du panneau (sinon max-height 90vh) */
29
46
  height?: string
30
47
  /** Arrondi des coins hauts : true | false | valeur CSS */
31
48
  rounded?: boolean | string
49
+ /** Ombre du panneau : `true` (défaut — automatiquement **adoucie** en mode `seamless`,
50
+ * où il n'y a pas de fond assombri derrière), `false` (aucune ombre) ou une valeur CSS
51
+ * (`"0 8px 30px rgb(0 0 0 / 0.25)"`). Surchargeable en CSS via `--q-bs-shadow`. */
52
+ shadow?: boolean | string
32
53
  dark?: boolean
33
54
  /** Fond translucide (frosted glass) : true = 70%, ou valeur % */
34
55
  translucent?: boolean | number
56
+ /** Glassmorphism marqué (même recette que `q-header` / `q-footer`) : fond très translucide
57
+ * + flou fort + bordure claire. **Prioritaire sur `translucent`** ; le flou et le fond
58
+ * des textes se règlent par `--q-glass-blur` / `--q-glass-bg`. */
59
+ glass?: boolean
35
60
  /** Seuil de drag (px) au-delà duquel on ferme */
36
61
  dragThreshold?: number
37
62
  /** Animation d'ouverture : slide-up (défaut) | fade | zoom */
@@ -40,18 +65,34 @@ interface Props {
40
65
  transitionDuration?: number
41
66
  /** Style du panneau (variables de thème pour les contenus téléportés) */
42
67
  contentStyle?: Record<string, string>
68
+ /** Points d'ancrage : fractions de la hauteur de vue où le panneau peut se poser,
69
+ * ex. `[0.25, 0.5, 0.75]` (comme Ionic). Le contenu reste défilable à chaque point
70
+ * d'ancrage et c'est la poignée qui fait grandir/rétrécir le panneau ; `0` est accepté
71
+ * et vaut « fermé ». Sans cette prop, le panneau prend la hauteur de son contenu
72
+ * (bornée par `max-height` / 90vh). */
73
+ breakpoints?: number[] | string
74
+ /** Point d'ancrage courant (`v-model:breakpoint`) : point de départ à l'ouverture, puis
75
+ * suivi des drags. Une valeur absente de `breakpoints` est ramenée au plus proche. */
76
+ breakpoint?: number
43
77
  }
44
78
 
45
79
  const props = withDefaults(defineProps<Props>(), {
46
80
  modelValue: false,
47
81
  persistent: false,
82
+ seamless: false,
48
83
  width: "640px",
49
84
  rounded: true,
85
+ shadow: true,
50
86
  dark: false,
87
+ glass: false,
51
88
  dragThreshold: 80,
52
89
  })
53
90
 
54
- const emit = defineEmits<{ "update:modelValue": [value: boolean] }>()
91
+ const emit = defineEmits<{
92
+ "update:modelValue": [value: boolean]
93
+ /** Nouveau point d'ancrage retenu (`v-model:breakpoint`) */
94
+ "update:breakpoint": [value: number]
95
+ }>()
55
96
 
56
97
  const open = computed({
57
98
  get: () => props.modelValue,
@@ -92,36 +133,132 @@ onBeforeUnmount(() => {
92
133
  }
93
134
  })
94
135
 
136
+ // — Points d'ancrage (breakpoints) : le panneau se pose sur des fractions de la hauteur
137
+ // de vue (`:breakpoints="[0.25, 0.5, 0.75]"`). Contrairement au drag historique (qui
138
+ // translate le panneau), on pilote ici sa **hauteur** : le contenu se réagence et reste
139
+ // défilable à chaque point d'ancrage (cf. `lib/bottomSheet.ts`, pur et testé).
140
+ const anchors = computed(() => normalizeBreakpoints(props.breakpoints))
141
+ const usesBreakpoints = computed(() => anchors.value.length > 0)
142
+
143
+ /** Fraction courante du panneau (1 = pleine hauteur) */
144
+ const currentRatio = ref(1)
145
+
146
+ /** Dernière valeur **communiquée** au parent (`v-model:breakpoint`) : évite un événement
147
+ * inutile quand le panneau retombe sur le même point d'ancrage. */
148
+ let committedRatio = 1
149
+
150
+ /** Point d'ancrage demandé par la prop `breakpoint`, ramené à la liste */
151
+ const requestedBreakpoint = computed(() => {
152
+ if (typeof props.breakpoint !== "number") return undefined
153
+ return usesBreakpoints.value
154
+ ? nearestBreakpoint(props.breakpoint, anchors.value)
155
+ : clampRatio(props.breakpoint)
156
+ })
157
+
158
+ watch(
159
+ [open, requestedBreakpoint, anchors],
160
+ () => {
161
+ if (!open.value) return
162
+ currentRatio.value = requestedBreakpoint.value ?? anchors.value[0] ?? 1
163
+ committedRatio = currentRatio.value
164
+ },
165
+ { immediate: true },
166
+ )
167
+
168
+ /** Hauteur de vue de référence (barre d'URL mobile exclue quand elle est connue) */
169
+ const viewportHeight = () =>
170
+ typeof window === "undefined" ? 0 : (window.visualViewport?.height ?? window.innerHeight)
171
+
172
+ /** Pose le panneau sur un point d'ancrage et prévient le parent si la valeur change */
173
+ const applyBreakpoint = (value: number) => {
174
+ currentRatio.value = value
175
+ if (value === committedRatio) return
176
+ committedRatio = value
177
+ emit("update:breakpoint", value)
178
+ }
179
+
180
+ const stepToBreakpoint = (direction: 1 | -1) => {
181
+ if (!usesBreakpoints.value) return
182
+ applyBreakpoint(stepBreakpoint(currentRatio.value, anchors.value, direction))
183
+ }
184
+
95
185
  // — Drag to dismiss (pattern vaul) —
96
186
  const panelRef = ref<HTMLElement | null>(null)
97
187
  const dragging = ref(false)
98
188
  let startY = 0
99
189
  let currentDy = 0
190
+ let startRatio = 1
100
191
 
101
192
  const onHandleDown = (e: PointerEvent) => {
102
- ;(e.target as HTMLElement).setPointerCapture(e.pointerId)
193
+ // capture du pointeur : peut échouer (pointeur déjà relâché, événement synthétique)
194
+ try {
195
+ ;(e.target as HTMLElement).setPointerCapture(e.pointerId)
196
+ } catch {
197
+ /* sans capture, le drag suit tant que le pointeur reste sur la poignée */
198
+ }
103
199
  startY = e.clientY
104
200
  currentDy = 0
201
+ startRatio = currentRatio.value
105
202
  dragging.value = true
106
203
  }
107
204
 
108
205
  const onHandleMove = (e: PointerEvent) => {
109
206
  if (!dragging.value) return
110
- currentDy = Math.max(0, e.clientY - startY)
207
+ currentDy = e.clientY - startY
208
+
209
+ if (usesBreakpoints.value) {
210
+ // La hauteur suit le doigt (bornée au plus haut point d'ancrage)
211
+ const highest = Math.max(...anchors.value)
212
+ currentRatio.value = Math.min(
213
+ highest,
214
+ ratioFromDrag(startRatio, currentDy, viewportHeight()),
215
+ )
216
+ return
217
+ }
218
+
219
+ currentDy = Math.max(0, currentDy)
111
220
  if (panelRef.value) panelRef.value.style.transform = `translateY(${currentDy}px)`
112
221
  }
113
222
 
114
223
  const onHandleUp = () => {
115
224
  if (!dragging.value) return
116
225
  dragging.value = false
226
+
227
+ if (usesBreakpoints.value) {
228
+ const height = viewportHeight()
229
+ const threshold = height > 0 ? props.dragThreshold / height : 0.15
230
+ const release = releaseBreakpoint(currentRatio.value, anchors.value, threshold)
231
+
232
+ if (release.close) {
233
+ open.value = false
234
+ currentRatio.value = anchors.value[0] ?? 1
235
+ committedRatio = currentRatio.value
236
+ } else if (release.breakpoint !== undefined) {
237
+ applyBreakpoint(release.breakpoint)
238
+ }
239
+
240
+ currentDy = 0
241
+ return
242
+ }
243
+
117
244
  if (currentDy > props.dragThreshold) open.value = false
118
245
  else if (panelRef.value) panelRef.value.style.transform = ""
119
246
  currentDy = 0
120
247
  }
121
248
 
249
+ /** Poignée au clavier : Entrée / Espace passe au point d'ancrage suivant (Maj : précédent) */
250
+ const onHandleKeydown = (e: KeyboardEvent) => {
251
+ if (!usesBreakpoints.value) return
252
+ if (e.key !== "Enter" && e.key !== " ") return
253
+ e.preventDefault()
254
+ stepToBreakpoint(e.shiftKey ? -1 : 1)
255
+ }
256
+
122
257
  const panelStyle = computed<Record<string, string>>(() => ({
123
258
  maxWidth: props.width,
124
- ...(props.height ? { height: props.height } : {}),
259
+ // `height` est ignoré en mode breakpoints : la hauteur vient du point d'ancrage
260
+ ...(props.height && !usesBreakpoints.value ? { height: props.height } : {}),
261
+ ...(usesBreakpoints.value ? { "--q-bs-breakpoint": `${currentRatio.value}` } : {}),
125
262
  }))
126
263
 
127
264
  const radiusStyle = computed<Record<string, string> | undefined>(() => {
@@ -130,6 +267,16 @@ const radiusStyle = computed<Record<string, string> | undefined>(() => {
130
267
  return undefined
131
268
  })
132
269
 
270
+ /** Ombre : `false` → aucune, une chaîne → telle quelle, `true` → le défaut CSS (adouci en
271
+ * `seamless`). On passe par `--q-bs-shadow` pour que le défaut reste thémable en CSS. */
272
+ const shadowStyle = computed<Record<string, string> | undefined>(() => {
273
+ if (props.shadow === false) return { "--q-bs-shadow": "none" }
274
+ if (typeof props.shadow === "string" && props.shadow.trim() !== "") {
275
+ return { "--q-bs-shadow": props.shadow.trim() }
276
+ }
277
+ return undefined
278
+ })
279
+
133
280
  // Nom de transition : q-bs (défaut slide-up) | q-bs-fade | q-bs-zoom
134
281
  const transitionName = computed(() =>
135
282
  props.transition ? `q-bs-${props.transition}` : "q-bs",
@@ -148,9 +295,11 @@ const transitionStyle = computed<Record<string, string> | undefined>(() =>
148
295
  const panelClasses = computed(() =>
149
296
  cn(
150
297
  "q-bottom-sheet__panel",
298
+ usesBreakpoints.value && "q-bottom-sheet__panel--breakpoints",
151
299
  props.rounded === false && "q-bottom-sheet__panel--square",
152
300
  props.dark && "q-bottom-sheet__panel--dark",
153
301
  (props.translucent === true || typeof props.translucent === "number") && "q-bottom-sheet__panel--translucent",
302
+ props.glass && "q-bottom-sheet__panel--glass",
154
303
  dragging.value && "q-bottom-sheet__panel--dragging",
155
304
  ),
156
305
  )
@@ -160,6 +309,18 @@ const translucentStyle = computed<Record<string, string> | undefined>(() =>
160
309
  ? { "--q-translucent-opacity": `${typeof props.translucent === "number" ? props.translucent : 70}%` }
161
310
  : undefined,
162
311
  )
312
+
313
+ defineExpose({
314
+ /** Point d'ancrage courant (fraction), quand `breakpoints` est utilisé */
315
+ breakpoint: computed(() => (usesBreakpoints.value ? currentRatio.value : undefined)),
316
+ /** Amène le panneau au point d'ancrage le plus proche de `value` */
317
+ setBreakpoint(value: number) {
318
+ if (!usesBreakpoints.value) return
319
+ applyBreakpoint(nearestBreakpoint(value, anchors.value))
320
+ },
321
+ /** Passe au point d'ancrage suivant (`1`) ou précédent (`-1`) */
322
+ stepBreakpoint: stepToBreakpoint,
323
+ })
163
324
  </script>
164
325
 
165
326
  <template>
@@ -173,23 +334,28 @@ const translucentStyle = computed<Record<string, string> | undefined>(() =>
173
334
  v-if="open"
174
335
  :ref="markOverlay"
175
336
  class="q-bottom-sheet__overlay"
176
- @click="!persistent && (open = false)"
337
+ :class="cn(seamless && 'q-bottom-sheet__overlay--seamless')"
338
+ @click="!persistent && !seamless && (open = false)"
177
339
  >
178
340
  <div
179
341
  ref="panelRef"
180
342
  class="q-bottom-sheet__panel"
181
343
  :class="panelClasses"
182
- :style="[panelStyle, radiusStyle, translucentStyle, transitionStyle, props.contentStyle]"
344
+ :style="[panelStyle, radiusStyle, shadowStyle, translucentStyle, transitionStyle, props.contentStyle]"
183
345
  role="dialog"
184
346
  aria-modal="true"
185
347
  @click.stop
186
348
  >
187
349
  <div
188
350
  class="q-bottom-sheet__handle"
351
+ :role="usesBreakpoints ? 'button' : undefined"
352
+ :tabindex="usesBreakpoints ? 0 : undefined"
353
+ :aria-label="usesBreakpoints ? 'Changer la hauteur du panneau' : undefined"
189
354
  @pointerdown="onHandleDown"
190
355
  @pointermove="onHandleMove"
191
356
  @pointerup="onHandleUp"
192
357
  @pointercancel="onHandleUp"
358
+ @keydown="onHandleKeydown"
193
359
  >
194
360
  <span class="q-bottom-sheet__handle-bar" />
195
361
  </div>
@@ -2,7 +2,8 @@
2
2
  // QImagePicker — API Quasar : <q-image-picker v-model="files" multiple label="Photos" max-file-size="5242880" />
3
3
  // Sélection d'images via input file masqué, avec aperçu en grille (object URLs),
4
4
  // suppression par tuile et validation (type, taille, nombre).
5
- import { computed, onBeforeUnmount, reactive, ref, watch } from "vue"
5
+ // `capture` ouvre directement l'appareil photo sur mobile.
6
+ import { computed, getCurrentInstance, onBeforeUnmount, reactive, ref, watch } from "vue"
6
7
  import { Icon } from "@iconify/vue"
7
8
  import { icons } from "../lib/icons"
8
9
  import { cn } from "../lib/utils"
@@ -18,6 +19,9 @@ interface Props {
18
19
  maxFileSize?: number
19
20
  /** Nombre max de fichiers (0 = illimité) */
20
21
  maxFiles?: number
22
+ /** Ouvre l'appareil photo sur mobile : `true` (appareil par défaut), `"user"` (frontale)
23
+ * ou `"environment"` (arrière) — attribut `capture` de l'input */
24
+ capture?: boolean | "user" | "environment"
21
25
  /** Label du champ */
22
26
  label?: string
23
27
  /** Texte de la tuile d'ajout (défaut : "Ajouter une image" / "Ajouter des images") */
@@ -38,6 +42,7 @@ const props = withDefaults(defineProps<Props>(), {
38
42
  accept: "image/*",
39
43
  maxFileSize: 0,
40
44
  maxFiles: 0,
45
+ capture: false,
41
46
  label: "",
42
47
  addLabel: "",
43
48
  hint: "",
@@ -122,6 +127,26 @@ const effectiveErrorMessage = computed(() => props.errorMessage || internalError
122
127
  // — Sélection —
123
128
  const inputEl = ref<HTMLInputElement | null>(null)
124
129
 
130
+ /** Attribut `capture` de l'input : `false` (défaut) → absent, `true` → appareil par
131
+ * défaut (`capture="true"`, tout navigateur y voit une demande de capture), sinon la
132
+ * valeur donnée (`user` / `environment`). */
133
+ const captureAttr = computed(() => (props.capture === false ? undefined : props.capture))
134
+
135
+ /** Garde-fou : sans écouteur `update:modelValue`, les fichiers choisis ne sont jamais
136
+ * conservés (la grille reste vide) — piège fréquent de ce champ. */
137
+ const instance = getCurrentInstance()
138
+ let warnedNoModel = false
139
+ const warnIfNotBound = () => {
140
+ if (warnedNoModel) return
141
+ if (!instance?.vnode.props?.["onUpdate:modelValue"]) {
142
+ warnedNoModel = true
143
+ console.warn(
144
+ "[q-image-picker] aucun `v-model` (ni écouteur `@update:model-value`) : les images " +
145
+ "choisies ne sont pas conservées",
146
+ )
147
+ }
148
+ }
149
+
125
150
  const pick = () => {
126
151
  if (props.disable || props.readonly) return
127
152
  inputEl.value?.click()
@@ -159,6 +184,7 @@ const addFiles = (incoming: File[]) => {
159
184
  for (const file of overflow) rejections.push({ file, reason: "count" })
160
185
 
161
186
  if (accepted.length > 0) {
187
+ warnIfNotBound()
162
188
  const next = props.multiple ? [...current, ...accepted] : accepted[0]!
163
189
  emit("update:modelValue", next)
164
190
  for (const file of accepted) emit("add", file)
@@ -242,6 +268,7 @@ const containerClasses = computed(() => cn("q-image-picker", props.disable && "q
242
268
  class="q-image-picker__input"
243
269
  :accept="accept"
244
270
  :multiple="multiple"
271
+ :capture="captureAttr"
245
272
  @change="onInputChange"
246
273
  />
247
274
  </div>