@dnax/ui 0.0.1

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 (150) hide show
  1. package/README.md +15 -0
  2. package/components/QAccordion.vue +68 -0
  3. package/components/QAccordionContent.vue +62 -0
  4. package/components/QAccordionItem.vue +24 -0
  5. package/components/QAccordionTrigger.vue +51 -0
  6. package/components/QActionSheet.vue +159 -0
  7. package/components/QApp.vue +8 -0
  8. package/components/QAutocomplete.vue +316 -0
  9. package/components/QAvatar.vue +92 -0
  10. package/components/QBackHeader.vue +69 -0
  11. package/components/QBackTop.vue +125 -0
  12. package/components/QBadge.vue +55 -0
  13. package/components/QBottomSheet.vue +177 -0
  14. package/components/QBottomSheetFooter.vue +9 -0
  15. package/components/QBottomSheetHeader.vue +35 -0
  16. package/components/QBottomSheetProvider.vue +62 -0
  17. package/components/QBottomSheetTrigger.vue +18 -0
  18. package/components/QBtn.vue +144 -0
  19. package/components/QBtnGroup.vue +31 -0
  20. package/components/QBubble.vue +36 -0
  21. package/components/QBubbleContent.vue +28 -0
  22. package/components/QBubbleGroup.vue +25 -0
  23. package/components/QBubbleReactions.vue +40 -0
  24. package/components/QCard.vue +63 -0
  25. package/components/QCardActions.vue +27 -0
  26. package/components/QCardContent.vue +29 -0
  27. package/components/QCarousel.vue +115 -0
  28. package/components/QCarouselContent.vue +23 -0
  29. package/components/QCarouselItem.vue +20 -0
  30. package/components/QCarouselNav.vue +51 -0
  31. package/components/QCheckbox.vue +146 -0
  32. package/components/QChip.vue +91 -0
  33. package/components/QCircularProgress.vue +122 -0
  34. package/components/QCol.vue +52 -0
  35. package/components/QCollapse.vue +141 -0
  36. package/components/QConfigProvider.vue +164 -0
  37. package/components/QContainer.vue +201 -0
  38. package/components/QCountDown.vue +164 -0
  39. package/components/QCountryPicker.vue +593 -0
  40. package/components/QDataGrid.vue +491 -0
  41. package/components/QDatePicker.vue +250 -0
  42. package/components/QDialog.vue +163 -0
  43. package/components/QDialogFooter.vue +9 -0
  44. package/components/QDialogHeader.vue +41 -0
  45. package/components/QDialogProvider.vue +65 -0
  46. package/components/QDialogTrigger.vue +18 -0
  47. package/components/QFab.vue +80 -0
  48. package/components/QFabAction.vue +48 -0
  49. package/components/QFilePicker.vue +294 -0
  50. package/components/QFooter.vue +72 -0
  51. package/components/QGallery.vue +280 -0
  52. package/components/QGrid.vue +54 -0
  53. package/components/QHeader.vue +38 -0
  54. package/components/QIcon.vue +53 -0
  55. package/components/QImagePicker.vue +248 -0
  56. package/components/QImagePreview.vue +225 -0
  57. package/components/QImagePreviewProvider.vue +35 -0
  58. package/components/QImg.vue +142 -0
  59. package/components/QInfiniteScroll.vue +109 -0
  60. package/components/QInnerLoading.vue +67 -0
  61. package/components/QInput.vue +289 -0
  62. package/components/QInputOtp.vue +238 -0
  63. package/components/QInputPassword.vue +67 -0
  64. package/components/QInputTag.vue +281 -0
  65. package/components/QIntersection.vue +119 -0
  66. package/components/QItem.vue +60 -0
  67. package/components/QItemSection.vue +47 -0
  68. package/components/QLinearProgress.vue +94 -0
  69. package/components/QList.vue +39 -0
  70. package/components/QLoading.vue +77 -0
  71. package/components/QLoadingProvider.vue +41 -0
  72. package/components/QMarquee.vue +56 -0
  73. package/components/QMessageScroller.vue +10 -0
  74. package/components/QMessageScrollerButton.vue +48 -0
  75. package/components/QMessageScrollerContent.vue +31 -0
  76. package/components/QMessageScrollerItem.vue +49 -0
  77. package/components/QMessageScrollerProvider.vue +198 -0
  78. package/components/QMessageScrollerViewport.vue +109 -0
  79. package/components/QNavMenu.vue +75 -0
  80. package/components/QNavMenuContent.vue +10 -0
  81. package/components/QNavMenuItem.vue +60 -0
  82. package/components/QNavMenuTrigger.vue +96 -0
  83. package/components/QNotifyProvider.vue +33 -0
  84. package/components/QNotifyToast.vue +60 -0
  85. package/components/QPage.vue +56 -0
  86. package/components/QPagination.vue +195 -0
  87. package/components/QParallax.vue +124 -0
  88. package/components/QPullToRefresh.vue +138 -0
  89. package/components/QRadio.vue +99 -0
  90. package/components/QRating.vue +105 -0
  91. package/components/QReorder.vue +191 -0
  92. package/components/QRollingText.vue +225 -0
  93. package/components/QRow.vue +23 -0
  94. package/components/QSafeArea.vue +39 -0
  95. package/components/QScrollArea.vue +118 -0
  96. package/components/QSelect.vue +698 -0
  97. package/components/QSeparator.vue +39 -0
  98. package/components/QSidebar.vue +153 -0
  99. package/components/QSidebarContent.vue +14 -0
  100. package/components/QSidebarFooter.vue +14 -0
  101. package/components/QSidebarHeader.vue +14 -0
  102. package/components/QSidebarMenu.vue +9 -0
  103. package/components/QSidebarMenuButton.vue +59 -0
  104. package/components/QSidebarMenuItem.vue +9 -0
  105. package/components/QSidebarTrigger.vue +32 -0
  106. package/components/QSkeleton.vue +95 -0
  107. package/components/QSlider.vue +205 -0
  108. package/components/QSpace.vue +10 -0
  109. package/components/QSpinner.vue +172 -0
  110. package/components/QSplitter.vue +145 -0
  111. package/components/QSticky.vue +181 -0
  112. package/components/QSwipeCell.vue +217 -0
  113. package/components/QSwiper.vue +102 -0
  114. package/components/QSwiperSlide.vue +19 -0
  115. package/components/QSyntax.vue +171 -0
  116. package/components/QTab.vue +175 -0
  117. package/components/QTabPanel.vue +50 -0
  118. package/components/QTabPanels.vue +106 -0
  119. package/components/QTable.vue +575 -0
  120. package/components/QTabs.vue +188 -0
  121. package/components/QText.vue +316 -0
  122. package/components/QTextCaption.vue +122 -0
  123. package/components/QToolbar.vue +28 -0
  124. package/components/QTooltip.vue +313 -0
  125. package/components/QUploader.vue +473 -0
  126. package/components/QVideo.vue +289 -0
  127. package/components/QVirtualScroll.vue +190 -0
  128. package/components/internal/QDateCalendar.vue +153 -0
  129. package/index.ts +151 -0
  130. package/lib/breakpoints.ts +44 -0
  131. package/lib/colors.ts +24 -0
  132. package/lib/config.ts +34 -0
  133. package/lib/countries.ts +261 -0
  134. package/lib/formula.ts +630 -0
  135. package/lib/icons.ts +38 -0
  136. package/lib/loading.ts +75 -0
  137. package/lib/overlayBack.ts +124 -0
  138. package/lib/platform.ts +146 -0
  139. package/lib/primitive.ts +25 -0
  140. package/lib/q.ts +287 -0
  141. package/lib/screen.ts +105 -0
  142. package/lib/search.ts +35 -0
  143. package/lib/themeVars.ts +22 -0
  144. package/lib/useComponentProps.ts +54 -0
  145. package/lib/utils.ts +7 -0
  146. package/module.ts +89 -0
  147. package/package.json +41 -0
  148. package/scripts/generate-exports.ts +56 -0
  149. package/styles/main.css +7627 -0
  150. package/tsconfig.json +33 -0
@@ -0,0 +1,38 @@
1
+ <script setup lang="ts">
2
+ // QHeader — barre haute. fixed = position: fixed en haut (opt-in).
3
+ // Safe-area top/left/right appliquées toujours (styles/main.css).
4
+ import { computed } from "vue"
5
+
6
+ interface Props {
7
+ /** Fixe la barre en haut de l'écran (sort du flux) */
8
+ fixed?: boolean
9
+ /** Fond translucide (frosted glass) : true = 70%, ou valeur % ("70") */
10
+ translucent?: boolean | number
11
+ /** Glassmorphism marqué : fond très translucide + flou fort + bordure claire */
12
+ glass?: boolean
13
+ }
14
+
15
+ const props = withDefaults(defineProps<Props>(), {
16
+ fixed: false,
17
+ })
18
+
19
+ const translucentStyle = computed<Record<string, string> | undefined>(() =>
20
+ props.translucent === true || typeof props.translucent === "number"
21
+ ? { "--q-translucent-opacity": `${typeof props.translucent === "number" ? props.translucent : 70}%` }
22
+ : undefined,
23
+ )
24
+ </script>
25
+
26
+ <template>
27
+ <div
28
+ class="q-header"
29
+ :class="{
30
+ 'q-header--fixed': fixed,
31
+ 'q-header--translucent': translucent === true || typeof translucent === 'number',
32
+ 'q-header--glass': glass,
33
+ }"
34
+ :style="translucentStyle"
35
+ >
36
+ <slot />
37
+ </div>
38
+ </template>
@@ -0,0 +1,53 @@
1
+ <script setup lang="ts">
2
+ // QIcon — API Quasar : <q-icon name="lucide:star" size="24px" color="primary" left right>
3
+ // name : icône Iconify par son nom (https://iconify.design/docs/icon-components/vue/)
4
+ import { computed } from "vue"
5
+ import { Icon } from "@iconify/vue"
6
+ import { cn } from "../lib/utils"
7
+ import { colorValue } from "../lib/colors"
8
+
9
+ const SIZE_MAP: Record<string, string> = { sm: "16px", md: "24px", lg: "32px", xl: "48px" }
10
+
11
+ interface Props {
12
+ /** Icône Iconify (ex. : "lucide:star") */
13
+ name?: string
14
+ /** Taille : sm|md|lg|xl ou valeur CSS ("1.5rem", "20px") */
15
+ size?: string
16
+ /** Couleur (token ou hex) */
17
+ color?: string
18
+ /** Marge à droite */
19
+ left?: boolean
20
+ /** Marge à gauche */
21
+ right?: boolean
22
+ }
23
+
24
+ const props = withDefaults(defineProps<Props>(), {
25
+ size: "md",
26
+ color: "",
27
+ left: false,
28
+ right: false,
29
+ })
30
+
31
+ const iconStyle = computed<Record<string, string>>(() => {
32
+ const style: Record<string, string> = {}
33
+ const size = SIZE_MAP[props.size] ?? props.size
34
+ style.width = size
35
+ style.height = size
36
+ if (props.color) style.color = colorValue(props.color)
37
+ return style
38
+ })
39
+
40
+ const iconClasses = computed(() => cn("q-icon", props.left && "q-icon--left", props.right && "q-icon--right"))
41
+ </script>
42
+
43
+ <template>
44
+ <Icon
45
+ v-if="name"
46
+ :icon="name"
47
+ class="q-icon"
48
+ :class="iconClasses"
49
+ :style="iconStyle"
50
+ aria-hidden="true"
51
+ />
52
+ <slot v-else />
53
+ </template>
@@ -0,0 +1,248 @@
1
+ <script setup lang="ts">
2
+ // QImagePicker — API Quasar : <q-image-picker v-model="files" multiple label="Photos" max-file-size="5242880" />
3
+ // Sélection d'images via input file masqué, avec aperçu en grille (object URLs),
4
+ // suppression par tuile et validation (type, taille, nombre).
5
+ import { computed, onBeforeUnmount, reactive, ref, watch } from "vue"
6
+ import { Icon } from "@iconify/vue"
7
+ import { icons } from "../lib/icons"
8
+ import { cn } from "../lib/utils"
9
+
10
+ interface Props {
11
+ /** Fichier(s) sélectionné(s) — File (simple) ou File[] (multiple) */
12
+ modelValue?: File | File[] | null
13
+ /** Autorise plusieurs fichiers */
14
+ multiple?: boolean
15
+ /** Types acceptés (mêmes règles qu'un input file) */
16
+ accept?: string
17
+ /** Taille max en octets (0 = illimité) */
18
+ maxFileSize?: number
19
+ /** Nombre max de fichiers (0 = illimité) */
20
+ maxFiles?: number
21
+ /** Label du champ */
22
+ label?: string
23
+ /** Texte de la tuile d'ajout (défaut : "Ajouter une image" / "Ajouter des images") */
24
+ addLabel?: string
25
+ /** Aide affichée sous la grille */
26
+ hint?: string
27
+ /** État d'erreur externe */
28
+ error?: boolean
29
+ /** Message d'erreur (surcharge l'erreur interne de validation) */
30
+ errorMessage?: string
31
+ disable?: boolean
32
+ readonly?: boolean
33
+ }
34
+
35
+ const props = withDefaults(defineProps<Props>(), {
36
+ modelValue: null,
37
+ multiple: false,
38
+ accept: "image/*",
39
+ maxFileSize: 0,
40
+ maxFiles: 0,
41
+ label: "",
42
+ addLabel: "",
43
+ hint: "",
44
+ error: false,
45
+ errorMessage: "",
46
+ disable: false,
47
+ readonly: false,
48
+ })
49
+
50
+ const emit = defineEmits<{
51
+ "update:modelValue": [value: File | File[] | null]
52
+ add: [file: File]
53
+ remove: [file: File]
54
+ rejected: [file: File, reason: "size" | "type" | "count"]
55
+ }>()
56
+
57
+ const files = computed<File[]>(() => {
58
+ const v = props.modelValue
59
+ if (v == null) return []
60
+ return Array.isArray(v) ? v : [v]
61
+ })
62
+
63
+ // — Aperçus : un object URL par fichier, révoqué à la suppression / au remplacement —
64
+ const previewUrls = reactive(new Map<File, string>())
65
+
66
+ const previewUrl = (file: File): string => {
67
+ if (!previewUrls.has(file)) previewUrls.set(file, URL.createObjectURL(file))
68
+ return previewUrls.get(file)!
69
+ }
70
+
71
+ const revoke = (file: File) => {
72
+ const url = previewUrls.get(file)
73
+ if (url) {
74
+ URL.revokeObjectURL(url)
75
+ previewUrls.delete(file)
76
+ }
77
+ }
78
+
79
+ // modelValue remplacé de l'extérieur → nettoie les aperçus des fichiers disparus
80
+ watch(files, (current) => {
81
+ for (const file of [...previewUrls.keys()]) {
82
+ if (!current.includes(file)) revoke(file)
83
+ }
84
+ })
85
+
86
+ onBeforeUnmount(() => {
87
+ for (const file of [...previewUrls.keys()]) revoke(file)
88
+ })
89
+
90
+ // — Validation —
91
+ const acceptsFile = (file: File): boolean => {
92
+ const accept = props.accept.trim()
93
+ if (!accept) return true
94
+ const patterns = accept
95
+ .split(",")
96
+ .map((p) => p.trim().toLowerCase())
97
+ .filter(Boolean)
98
+ if (patterns.length === 0) return true
99
+ const name = file.name.toLowerCase()
100
+ return patterns.some((p) => {
101
+ if (p === "image/*") return file.type.startsWith("image/")
102
+ if (p === "video/*") return file.type.startsWith("video/")
103
+ if (p === "audio/*") return file.type.startsWith("audio/")
104
+ if (p.startsWith(".")) return name.endsWith(p)
105
+ if (p.includes("/")) return file.type.toLowerCase() === p
106
+ return false
107
+ })
108
+ }
109
+
110
+ const formatMaxSize = () => {
111
+ const bytes = props.maxFileSize
112
+ if (bytes >= 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))} Mo`
113
+ if (bytes >= 1024) return `${Math.round(bytes / 1024)} Ko`
114
+ return `${bytes} octets`
115
+ }
116
+
117
+ const internalError = ref("")
118
+
119
+ const showError = computed(() => props.error || internalError.value !== "")
120
+ const effectiveErrorMessage = computed(() => props.errorMessage || internalError.value)
121
+
122
+ // — Sélection —
123
+ const inputEl = ref<HTMLInputElement | null>(null)
124
+
125
+ const pick = () => {
126
+ if (props.disable || props.readonly) return
127
+ inputEl.value?.click()
128
+ }
129
+
130
+ const onInputChange = (e: Event) => {
131
+ const input = e.target as HTMLInputElement
132
+ const selected = Array.from(input.files ?? [])
133
+ input.value = "" // permet de re-sélectionner le même fichier
134
+ internalError.value = ""
135
+ if (selected.length > 0) addFiles(selected)
136
+ }
137
+
138
+ const addFiles = (incoming: File[]) => {
139
+ const current = files.value
140
+ const valid: File[] = []
141
+ const rejections: { file: File; reason: "size" | "type" | "count" }[] = []
142
+
143
+ for (const file of incoming) {
144
+ if (props.maxFileSize > 0 && file.size > props.maxFileSize) {
145
+ rejections.push({ file, reason: "size" })
146
+ continue
147
+ }
148
+ if (!acceptsFile(file)) {
149
+ rejections.push({ file, reason: "type" })
150
+ continue
151
+ }
152
+ valid.push(file)
153
+ }
154
+
155
+ const quota = props.maxFiles > 0 ? props.maxFiles : Infinity
156
+ const remaining = quota === Infinity ? Infinity : Math.max(0, quota - current.length)
157
+ const accepted = remaining === Infinity ? valid : valid.slice(0, remaining)
158
+ const overflow = remaining === Infinity ? [] : valid.slice(remaining)
159
+ for (const file of overflow) rejections.push({ file, reason: "count" })
160
+
161
+ if (accepted.length > 0) {
162
+ const next = props.multiple ? [...current, ...accepted] : accepted[0]!
163
+ emit("update:modelValue", next)
164
+ for (const file of accepted) emit("add", file)
165
+ }
166
+
167
+ if (rejections.length > 0) {
168
+ const reasons = [...new Set(rejections.map((r) => r.reason))]
169
+ const parts: string[] = []
170
+ if (reasons.includes("size")) parts.push(`Fichier trop lourd (max ${formatMaxSize()})`)
171
+ if (reasons.includes("type")) parts.push("Type de fichier non accepté")
172
+ if (reasons.includes("count")) parts.push(`Nombre maximum : ${props.maxFiles}`)
173
+ internalError.value = parts.join(" · ")
174
+ for (const r of rejections) emit("rejected", r.file, r.reason)
175
+ }
176
+ }
177
+
178
+ const removeFile = (file: File) => {
179
+ if (props.disable || props.readonly) return
180
+ revoke(file)
181
+ const next = files.value.filter((f) => f !== file)
182
+ emit("update:modelValue", props.multiple ? next : (next[0] ?? null))
183
+ emit("remove", file)
184
+ }
185
+
186
+ const canAdd = computed(() => {
187
+ if (props.disable || props.readonly) return false
188
+ if (!props.multiple && files.value.length > 0) return true // remplacement
189
+ if (props.maxFiles > 0 && files.value.length >= props.maxFiles) return false
190
+ return true
191
+ })
192
+
193
+ const defaultAddLabel = computed(() =>
194
+ props.addLabel || (props.multiple ? "Ajouter des images" : "Ajouter une image"),
195
+ )
196
+
197
+ const containerClasses = computed(() => cn("q-image-picker", props.disable && "q-field--disabled"))
198
+ </script>
199
+
200
+ <template>
201
+ <div class="q-image-picker" :class="containerClasses">
202
+ <label v-if="label" class="q-field__label-stack">{{ label }}</label>
203
+
204
+ <div class="q-image-picker__grid">
205
+ <div v-for="file in files" :key="previewUrl(file)" class="q-image-picker__item">
206
+ <img class="q-image-picker__thumb" :src="previewUrl(file)" :alt="file.name" />
207
+ <button
208
+ v-if="!disable && !readonly"
209
+ type="button"
210
+ class="q-image-picker__remove"
211
+ aria-label="Retirer l'image"
212
+ @click="removeFile(file)"
213
+ >
214
+ <Icon :icon="icons.x" aria-hidden="true" />
215
+ </button>
216
+ </div>
217
+
218
+ <button
219
+ v-if="canAdd"
220
+ type="button"
221
+ class="q-image-picker__add"
222
+ :disabled="disable"
223
+ @click="pick"
224
+ >
225
+ <Icon :icon="icons.imagePlus" class="q-image-picker__add-icon" />
226
+ <span v-if="defaultAddLabel" class="q-image-picker__add-label">{{ defaultAddLabel }}</span>
227
+ </button>
228
+ </div>
229
+
230
+ <div class="q-field__bottom">
231
+ <div v-if="showError" class="q-field__error">
232
+ <slot name="error">{{ effectiveErrorMessage }}</slot>
233
+ </div>
234
+ <div v-else-if="hint || $slots.hint" class="q-field__hint">
235
+ <slot name="hint">{{ hint }}</slot>
236
+ </div>
237
+ </div>
238
+
239
+ <input
240
+ ref="inputEl"
241
+ type="file"
242
+ class="q-image-picker__input"
243
+ :accept="accept"
244
+ :multiple="multiple"
245
+ @change="onInputChange"
246
+ />
247
+ </div>
248
+ </template>
@@ -0,0 +1,225 @@
1
+ <script setup lang="ts">
2
+ // QImagePreview — visionneuse plein écran : <q-image-preview v-model="open" :images="urls" v-model:index="i" transition="up" close-btn />
3
+ // Navigation par carrousel (QSwiper) : swipe horizontal, flèches, clavier.
4
+ // Swipe vers le bas pour fermer, × / Échap / clic sur le fond aussi.
5
+ import { computed, onBeforeUnmount, ref, watch } from "vue"
6
+ import { Icon } from "@iconify/vue"
7
+ import { icons } from "../lib/icons"
8
+ import { useOverlayBack } from "../lib/overlayBack"
9
+ import QSwiper from "./QSwiper.vue"
10
+ import QSwiperSlide from "./QSwiperSlide.vue"
11
+
12
+ type PreviewTransition = "fade" | "up" | "down" | "zoom"
13
+
14
+ interface Props {
15
+ /** Ouvert (v-model) */
16
+ modelValue?: boolean
17
+ /** URLs d'images — string[] ou { src, title? }[] */
18
+ images?: (string | { src: string; title?: string })[]
19
+ /** Image courante (v-model:index) */
20
+ index?: number
21
+ /** Transition d'ouverture : fade | up | down | zoom (défaut fade) */
22
+ transition?: PreviewTransition
23
+ /** Affiche un bouton fermer (×) */
24
+ closeBtn?: boolean
25
+ /** Affiche le compteur "n / N" (défaut true) */
26
+ counter?: boolean
27
+ /** Fermer en cliquant sur le fond (défaut true) */
28
+ closeOnBackdrop?: boolean
29
+ /** Fermer en glissant vers le bas (défaut true) */
30
+ closeOnSwipeDown?: boolean
31
+ /** Boutons précédent/suivant (défaut true) */
32
+ nav?: boolean
33
+ }
34
+
35
+ const props = withDefaults(defineProps<Props>(), {
36
+ modelValue: false,
37
+ images: () => [],
38
+ index: 0,
39
+ transition: "fade",
40
+ closeBtn: false,
41
+ counter: true,
42
+ closeOnBackdrop: true,
43
+ closeOnSwipeDown: true,
44
+ nav: true,
45
+ })
46
+
47
+ const emit = defineEmits<{
48
+ "update:modelValue": [value: boolean]
49
+ "update:index": [value: number]
50
+ /** Émis quand la visionneuse se ferme (swipe bas, backdrop, ×, Escape, v-model=false) */
51
+ dismiss: []
52
+ }>()
53
+
54
+ const open = computed({
55
+ get: () => props.modelValue,
56
+ set: (v) => emit("update:modelValue", v),
57
+ })
58
+ const idx = computed(() => Math.min(Math.max(props.index, 0), Math.max(0, props.images.length - 1)))
59
+
60
+ // « Retour » navigateur → ferme la visionneuse au lieu de naviguer
61
+ useOverlayBack(open, () => { open.value = false }, "QImagePreview")
62
+
63
+ const srcOf = (item: string | { src: string; title?: string }) =>
64
+ typeof item === "string" ? item : item.src
65
+
66
+ // — Carrousel (QSwiper) —
67
+ const swiperInst = ref<any>(null)
68
+ const onSwiper = (s: any) => {
69
+ swiperInst.value = s
70
+ }
71
+ const onSlideChange = (s: any) => {
72
+ if (s && typeof s.realIndex === "number") emit("update:index", s.realIndex)
73
+ }
74
+ watch(
75
+ () => props.index,
76
+ (v) => {
77
+ if (swiperInst.value && typeof v === "number") swiperInst.value.slideTo(idx.value, 0)
78
+ },
79
+ )
80
+
81
+ const prev = () => {
82
+ const s = swiperInst.value
83
+ if (!s || props.images.length < 2) return
84
+ if (s.activeIndex === 0) s.slideTo(props.images.length - 1)
85
+ else s.slidePrev()
86
+ }
87
+ const next = () => {
88
+ const s = swiperInst.value
89
+ if (!s || props.images.length < 2) return
90
+ if (s.activeIndex === props.images.length - 1) s.slideTo(0)
91
+ else s.slideNext()
92
+ }
93
+ const close = () => {
94
+ open.value = false
95
+ }
96
+
97
+ // — Swipe vers le bas pour fermer (geste vertical ; l'horizontal est géré par le carrousel) —
98
+ const panelTransform = ref("")
99
+ let startX = 0
100
+ let startY = 0
101
+ let verticalDrag = false
102
+ let moved = false
103
+ let cleanup: (() => void) | null = null
104
+
105
+ const onWrapperDown = (e: PointerEvent) => {
106
+ startX = e.clientX
107
+ startY = e.clientY
108
+ verticalDrag = false
109
+ moved = false
110
+
111
+ const onMove = (ev: PointerEvent) => {
112
+ const dx = ev.clientX - startX
113
+ const dy = ev.clientY - startY
114
+ if (Math.abs(dx) + Math.abs(dy) > 8) moved = true
115
+ if (!verticalDrag) {
116
+ if (dy > 0 && Math.abs(dy) > Math.abs(dx)) verticalDrag = true
117
+ else return // drag horizontal → laissé au carrousel
118
+ }
119
+ panelTransform.value = `translate3d(0, ${Math.max(0, dy) * 0.35}px, 0)`
120
+ }
121
+ const onUp = (ev: PointerEvent) => {
122
+ const dy = ev.clientY - startY
123
+ panelTransform.value = ""
124
+ if (verticalDrag && dy > 80 && props.closeOnSwipeDown) close()
125
+ if (cleanup) cleanup()
126
+ }
127
+ window.addEventListener("pointermove", onMove)
128
+ window.addEventListener("pointerup", onUp)
129
+ window.addEventListener("pointercancel", onUp)
130
+ cleanup = () => {
131
+ window.removeEventListener("pointermove", onMove)
132
+ window.removeEventListener("pointerup", onUp)
133
+ window.removeEventListener("pointercancel", onUp)
134
+ cleanup = null
135
+ }
136
+ }
137
+
138
+ const onSlideClick = () => {
139
+ if (moved) return // ignore après un swipe
140
+ if (props.closeOnBackdrop) close()
141
+ }
142
+
143
+ // — Clavier (Escape, flèches) quand ouvert —
144
+ const onKeydown = (e: KeyboardEvent) => {
145
+ if (e.key === "Escape") close()
146
+ else if (e.key === "ArrowLeft") prev()
147
+ else if (e.key === "ArrowRight") next()
148
+ }
149
+
150
+ watch(open, (v) => {
151
+ if (v) {
152
+ window.addEventListener("keydown", onKeydown)
153
+ document.body.style.overflow = "hidden"
154
+ } else {
155
+ window.removeEventListener("keydown", onKeydown)
156
+ document.body.style.overflow = ""
157
+ emit("dismiss")
158
+ }
159
+ })
160
+ onBeforeUnmount(() => {
161
+ window.removeEventListener("keydown", onKeydown)
162
+ document.body.style.overflow = ""
163
+ if (cleanup) cleanup()
164
+ })
165
+ </script>
166
+
167
+ <template>
168
+ <Teleport to="body">
169
+ <Transition :name="`q-image-preview-${transition}`">
170
+ <div
171
+ v-if="open"
172
+ class="q-image-preview"
173
+ :style="panelTransform ? { transform: panelTransform, transition: 'none' } : undefined"
174
+ @pointerdown="onWrapperDown"
175
+ >
176
+ <q-swiper
177
+ :initial-slide="idx"
178
+ :slides-per-view="1"
179
+ class="q-image-preview__swiper"
180
+ @swiper="onSwiper"
181
+ @slide-change="onSlideChange"
182
+ >
183
+ <q-swiper-slide v-for="(img, i) in images" :key="i">
184
+ <div class="q-image-preview__slide" @click="onSlideClick">
185
+ <img :src="srcOf(img)" :alt="'Image ' + (i + 1)" draggable="false" />
186
+ </div>
187
+ </q-swiper-slide>
188
+ </q-swiper>
189
+
190
+ <button
191
+ v-if="closeBtn"
192
+ type="button"
193
+ class="q-image-preview__btn q-image-preview__close"
194
+ :aria-label="'Fermer'"
195
+ @click="close"
196
+ >
197
+ <Icon :icon="icons.x" />
198
+ </button>
199
+
200
+ <template v-if="nav && images.length > 1">
201
+ <button
202
+ type="button"
203
+ class="q-image-preview__btn q-image-preview__nav q-image-preview__nav--prev"
204
+ :aria-label="'Précédente'"
205
+ @click="prev"
206
+ >
207
+ <Icon :icon="icons.chevronLeft" />
208
+ </button>
209
+ <button
210
+ type="button"
211
+ class="q-image-preview__btn q-image-preview__nav q-image-preview__nav--next"
212
+ :aria-label="'Suivante'"
213
+ @click="next"
214
+ >
215
+ <Icon :icon="icons.chevronRight" />
216
+ </button>
217
+ </template>
218
+
219
+ <div v-if="counter && images.length" class="q-image-preview__counter">
220
+ {{ idx + 1 }} / {{ images.length }}
221
+ </div>
222
+ </div>
223
+ </Transition>
224
+ </Teleport>
225
+ </template>
@@ -0,0 +1,35 @@
1
+ <script setup lang="ts">
2
+ // QImagePreviewProvider — rend la pile de visionneuses programmatiques ($q.imagePreview).
3
+ // Intégré dans QConfigProvider (rendu automatiquement par le plus externe),
4
+ // mais utilisable aussi en autonome : <q-image-preview-provider><slot /></q-image-preview-provider>.
5
+ // Chaque entrée de la pile est rendue dans un <q-image-preview> ; quand la
6
+ // visionneuse se ferme, l'entrée est retirée de la pile (onDismiss appelé).
7
+ import { imagePreviewStack, closeImagePreview } from "../lib/q"
8
+ import type { ImagePreviewController } from "../lib/q"
9
+
10
+ const stack = imagePreviewStack
11
+
12
+ const handleDismiss = (ctrl: ImagePreviewController) => {
13
+ ctrl._opts.onDismiss?.()
14
+ closeImagePreview(ctrl)
15
+ }
16
+ </script>
17
+
18
+ <template>
19
+ <slot />
20
+ <q-image-preview
21
+ v-for="(entry, idx) in stack"
22
+ :key="idx"
23
+ :model-value="true"
24
+ :images="entry._opts.images"
25
+ :index="entry._opts.index ?? 0"
26
+ :transition="entry._opts.transition ?? 'fade'"
27
+ :close-btn="entry._opts.closeBtn"
28
+ :counter="entry._opts.counter"
29
+ :close-on-backdrop="entry._opts.closeOnBackdrop"
30
+ :close-on-swipe-down="entry._opts.closeOnSwipeDown"
31
+ :nav="entry._opts.nav"
32
+ @update:model-value="(v: boolean) => !v && handleDismiss(entry)"
33
+ @update:index="(i: number) => (entry._opts.index = i)"
34
+ />
35
+ </template>