@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,294 @@
1
+ <script setup lang="ts">
2
+ // QFilePicker — API Quasar : <q-file-picker v-model="files" multiple label="Pièces jointes" />
3
+ // Sélection de fichiers (input masqué) : aperçu vignette pour les images,
4
+ // icône typée + nom + taille sinon ; 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 ; vide = tout) */
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 du bouton d'ajout (défaut : "Ajouter un fichier" / "Ajouter des fichiers") */
24
+ addLabel?: string
25
+ /** Aide affichée sous la liste */
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: "",
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 (images) : 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
+ watch(files, (current) => {
80
+ for (const file of [...previewUrls.keys()]) {
81
+ if (!current.includes(file)) revoke(file)
82
+ }
83
+ })
84
+
85
+ onBeforeUnmount(() => {
86
+ for (const file of [...previewUrls.keys()]) revoke(file)
87
+ })
88
+
89
+ const isImage = (file: File) => file.type.startsWith("image/")
90
+
91
+ // — Clé de rendu stable par instance de File (un File n'a pas d'id natif) —
92
+ const fileIds = new WeakMap<File, number>()
93
+ let fileIdCounter = 0
94
+ const fileKey = (file: File): number => {
95
+ let id = fileIds.get(file)
96
+ if (id === undefined) {
97
+ id = ++fileIdCounter
98
+ fileIds.set(file, id)
99
+ }
100
+ return id
101
+ }
102
+
103
+ // — Icône par type de fichier (fallback générique) —
104
+ const ARCHIVE_EXT = /\.(zip|rar|7z|tar|gz|bz2|xz|tgz)$/
105
+ const SPREADSHEET_EXT = /\.(xlsx|xls|csv|ods|tsv)$/
106
+ const CODE_EXT = /\.(json|js|mjs|cjs|ts|jsx|tsx|html?|xml|css|scss|sass|less|vue|py|java|c|cpp|h|hpp|sh|bash|yml|yaml|toml|ini|go|rs|php|rb|swift|kt)$/
107
+
108
+ const iconFor = (file: File): string => {
109
+ const type = file.type
110
+ const name = file.name.toLowerCase()
111
+ if (type.startsWith("video/")) return icons.fileVideo
112
+ if (type.startsWith("audio/")) return icons.fileAudio
113
+ if (ARCHIVE_EXT.test(name) || /zip|compressed/.test(type)) return icons.fileArchive
114
+ if (SPREADSHEET_EXT.test(name) || type.includes("spreadsheet") || type.includes("excel")) {
115
+ return icons.fileSpreadsheet
116
+ }
117
+ if (CODE_EXT.test(name)) return icons.fileCode
118
+ if (type.startsWith("image/")) return icons.fileImage
119
+ return icons.fileText
120
+ }
121
+
122
+ const formatSize = (bytes: number): string => {
123
+ if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} Go`
124
+ if (bytes >= 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))} Mo`
125
+ if (bytes >= 1024) return `${Math.round(bytes / 1024)} Ko`
126
+ return `${bytes} o`
127
+ }
128
+
129
+ // — Validation —
130
+ const acceptsFile = (file: File): boolean => {
131
+ const accept = props.accept.trim()
132
+ if (!accept) return true
133
+ const patterns = accept
134
+ .split(",")
135
+ .map((p) => p.trim().toLowerCase())
136
+ .filter(Boolean)
137
+ if (patterns.length === 0) return true
138
+ const name = file.name.toLowerCase()
139
+ return patterns.some((p) => {
140
+ if (p === "image/*") return file.type.startsWith("image/")
141
+ if (p === "video/*") return file.type.startsWith("video/")
142
+ if (p === "audio/*") return file.type.startsWith("audio/")
143
+ if (p.startsWith(".")) return name.endsWith(p)
144
+ if (p.includes("/")) return file.type.toLowerCase() === p
145
+ return false
146
+ })
147
+ }
148
+
149
+ const formatMaxSize = () => {
150
+ const bytes = props.maxFileSize
151
+ if (bytes >= 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))} Mo`
152
+ if (bytes >= 1024) return `${Math.round(bytes / 1024)} Ko`
153
+ return `${bytes} octets`
154
+ }
155
+
156
+ const internalError = ref("")
157
+
158
+ const showError = computed(() => props.error || internalError.value !== "")
159
+ const effectiveErrorMessage = computed(() => props.errorMessage || internalError.value)
160
+
161
+ // — Sélection —
162
+ const inputEl = ref<HTMLInputElement | null>(null)
163
+
164
+ const pick = () => {
165
+ if (props.disable || props.readonly) return
166
+ inputEl.value?.click()
167
+ }
168
+
169
+ const onInputChange = (e: Event) => {
170
+ const input = e.target as HTMLInputElement
171
+ const selected = Array.from(input.files ?? [])
172
+ input.value = "" // permet de re-sélectionner le même fichier
173
+ internalError.value = ""
174
+ if (selected.length > 0) addFiles(selected)
175
+ }
176
+
177
+ const addFiles = (incoming: File[]) => {
178
+ const current = files.value
179
+ const valid: File[] = []
180
+ const rejections: { file: File; reason: "size" | "type" | "count" }[] = []
181
+
182
+ for (const file of incoming) {
183
+ if (props.maxFileSize > 0 && file.size > props.maxFileSize) {
184
+ rejections.push({ file, reason: "size" })
185
+ continue
186
+ }
187
+ if (!acceptsFile(file)) {
188
+ rejections.push({ file, reason: "type" })
189
+ continue
190
+ }
191
+ valid.push(file)
192
+ }
193
+
194
+ const quota = props.maxFiles > 0 ? props.maxFiles : Infinity
195
+ const remaining = quota === Infinity ? Infinity : Math.max(0, quota - current.length)
196
+ const accepted = remaining === Infinity ? valid : valid.slice(0, remaining)
197
+ const overflow = remaining === Infinity ? [] : valid.slice(remaining)
198
+ for (const file of overflow) rejections.push({ file, reason: "count" })
199
+
200
+ if (accepted.length > 0) {
201
+ const next = props.multiple ? [...current, ...accepted] : accepted[0]!
202
+ emit("update:modelValue", next)
203
+ for (const file of accepted) emit("add", file)
204
+ }
205
+
206
+ if (rejections.length > 0) {
207
+ const reasons = [...new Set(rejections.map((r) => r.reason))]
208
+ const parts: string[] = []
209
+ if (reasons.includes("size")) parts.push(`Fichier trop lourd (max ${formatMaxSize()})`)
210
+ if (reasons.includes("type")) parts.push("Type de fichier non accepté")
211
+ if (reasons.includes("count")) parts.push(`Nombre maximum : ${props.maxFiles}`)
212
+ internalError.value = parts.join(" · ")
213
+ for (const r of rejections) emit("rejected", r.file, r.reason)
214
+ }
215
+ }
216
+
217
+ const removeFile = (file: File) => {
218
+ if (props.disable || props.readonly) return
219
+ revoke(file)
220
+ const next = files.value.filter((f) => f !== file)
221
+ emit("update:modelValue", props.multiple ? next : (next[0] ?? null))
222
+ emit("remove", file)
223
+ }
224
+
225
+ const canAdd = computed(() => {
226
+ if (props.disable || props.readonly) return false
227
+ if (!props.multiple && files.value.length > 0) return true // remplacement
228
+ if (props.maxFiles > 0 && files.value.length >= props.maxFiles) return false
229
+ return true
230
+ })
231
+
232
+ const defaultAddLabel = computed(() =>
233
+ props.addLabel || (props.multiple ? "Ajouter des fichiers" : "Ajouter un fichier"),
234
+ )
235
+
236
+ const containerClasses = computed(() => cn("q-file-picker", props.disable && "q-field--disabled"))
237
+ </script>
238
+
239
+ <template>
240
+ <div class="q-file-picker" :class="containerClasses">
241
+ <label v-if="label" class="q-field__label-stack">{{ label }}</label>
242
+
243
+ <div class="q-file-picker__list">
244
+ <div v-for="file in files" :key="fileKey(file)" class="q-file-picker__row">
245
+ <div class="q-file-picker__preview">
246
+ <img v-if="isImage(file)" class="q-file-picker__thumb" :src="previewUrl(file)" :alt="file.name" />
247
+ <Icon :icon="iconFor(file)" v-else class="q-file-picker__file-icon" />
248
+ </div>
249
+ <div class="q-file-picker__info">
250
+ <span class="q-file-picker__name">{{ file.name }}</span>
251
+ <span class="q-file-picker__size">{{ formatSize(file.size) }}</span>
252
+ </div>
253
+ <button
254
+ v-if="!disable && !readonly"
255
+ type="button"
256
+ class="q-file-picker__remove"
257
+ aria-label="Retirer le fichier"
258
+ @click="removeFile(file)"
259
+ >
260
+ <Icon :icon="icons.x" aria-hidden="true" />
261
+ </button>
262
+ </div>
263
+
264
+ <button
265
+ v-if="canAdd"
266
+ type="button"
267
+ class="q-file-picker__add"
268
+ :disabled="disable"
269
+ @click="pick"
270
+ >
271
+ <Icon :icon="icons.filePlus" class="q-file-picker__add-icon" />
272
+ <span class="q-file-picker__add-label">{{ defaultAddLabel }}</span>
273
+ </button>
274
+ </div>
275
+
276
+ <div class="q-field__bottom">
277
+ <div v-if="showError" class="q-field__error">
278
+ <slot name="error">{{ effectiveErrorMessage }}</slot>
279
+ </div>
280
+ <div v-else-if="hint || $slots.hint" class="q-field__hint">
281
+ <slot name="hint">{{ hint }}</slot>
282
+ </div>
283
+ </div>
284
+
285
+ <input
286
+ ref="inputEl"
287
+ type="file"
288
+ class="q-file-picker__input"
289
+ :accept="accept"
290
+ :multiple="multiple"
291
+ @change="onInputChange"
292
+ />
293
+ </div>
294
+ </template>
@@ -0,0 +1,72 @@
1
+ <script setup lang="ts">
2
+ // QFooter — barre basse (safe-area bottom appliquée par styles/main.css : .q-app .q-footer)
3
+ import { computed, onBeforeUnmount, onMounted, ref } from "vue"
4
+
5
+ interface Props {
6
+ /** Fixe la barre en bas de l'écran (sort du flux) */
7
+ fixed?: boolean
8
+ /** Ombre portée vers le haut */
9
+ elevated?: boolean
10
+ /** Masqué au scroll vers le bas, réaffiché au scroll vers le haut */
11
+ reveal?: boolean
12
+ /** Bordure supérieure */
13
+ bordered?: boolean
14
+ /** Fond translucide (frosted glass) : true = 70%, ou valeur % */
15
+ translucent?: boolean | number
16
+ /** Glassmorphism marqué : fond très translucide + flou fort + bordure claire */
17
+ glass?: boolean
18
+ }
19
+
20
+ const props = withDefaults(defineProps<Props>(), {
21
+ fixed: false,
22
+ elevated: false,
23
+ reveal: false,
24
+ bordered: false,
25
+ })
26
+
27
+ const translucentStyle = computed<Record<string, string> | undefined>(() =>
28
+ props.translucent === true || typeof props.translucent === "number"
29
+ ? { "--q-translucent-opacity": `${typeof props.translucent === "number" ? props.translucent : 70}%` }
30
+ : undefined,
31
+ )
32
+
33
+ // reveal : écoute le scroll de la fenêtre
34
+ const visible = ref(true)
35
+ let lastY = 0
36
+
37
+ const onScroll = () => {
38
+ if (typeof window === "undefined") return
39
+ const y = window.scrollY
40
+ visible.value = y <= 0 || y < lastY // haut de page ou scroll up → visible
41
+ lastY = y
42
+ }
43
+
44
+ onMounted(() => {
45
+ if (typeof window !== "undefined") {
46
+ window.addEventListener("scroll", onScroll, { passive: true })
47
+ }
48
+ })
49
+ onBeforeUnmount(() => {
50
+ if (typeof window !== "undefined") {
51
+ window.removeEventListener("scroll", onScroll)
52
+ }
53
+ })
54
+ </script>
55
+
56
+ <template>
57
+ <footer
58
+ class="q-footer"
59
+ :class="{
60
+ 'q-footer--fixed': fixed,
61
+ 'q-footer--elevated': props.elevated,
62
+ 'q-footer--bordered': props.bordered,
63
+ 'q-footer--reveal': props.reveal,
64
+ 'q-footer--reveal-hidden': props.reveal && !visible,
65
+ 'q-footer--translucent': props.translucent === true || typeof props.translucent === 'number',
66
+ 'q-footer--glass': props.glass,
67
+ }"
68
+ :style="translucentStyle"
69
+ >
70
+ <slot />
71
+ </footer>
72
+ </template>
@@ -0,0 +1,280 @@
1
+ <script setup lang="ts">
2
+ // QGallery — galerie d'images avec sélection : <q-gallery v-model="sel" :images="imgs" multiple labels />
3
+ // images : URLs (string) ou objets { src, label, description }.
4
+ // v-model : item/URL (simple) ou tableau (multiple) ; la valeur renvoyée est l'item brut
5
+ // passé dans `images` (string ou objet). Props : labels (nom + description sous l'image),
6
+ // selectable, cols, max-selected, rounded, dense.
7
+ import { computed } from "vue"
8
+ import { Icon } from "@iconify/vue"
9
+ import { icons } from "../lib/icons"
10
+ import { cn } from "../lib/utils"
11
+
12
+ /** Une image normalisée de la galerie : URL + nom/description optionnels */
13
+ export interface GalleryImage {
14
+ /** URL de l'image */
15
+ src: string
16
+ /** Nom affiché sous l'image (si labels) */
17
+ label?: string
18
+ /** Description affichée sous le nom (si labels) */
19
+ description?: string
20
+ }
21
+
22
+ type GalleryValue = string | GalleryImage
23
+
24
+ interface Props {
25
+ /** Sélection : URL/item (simple) ou tableau (multiple) — v-model */
26
+ modelValue?: GalleryValue | GalleryValue[] | null
27
+ /** Liste des images : URLs ou objets JSON (les champs sont donnés par src-key/label-key/description-key) */
28
+ images?: (string | Record<string, unknown>)[]
29
+ /** Sélection multiple (v-model = tableau) */
30
+ multiple?: boolean
31
+ /** Affiche le nom et la description sous les images */
32
+ labels?: boolean
33
+ /** Champ des objets contenant l'URL de l'image (défaut "src") */
34
+ srcKey?: string
35
+ /** Champ des objets contenant le nom (défaut "label") */
36
+ labelKey?: string
37
+ /** Champ des objets contenant la description (défaut "description") */
38
+ descriptionKey?: string
39
+ /** Désactive la sélection (affichage seul) */
40
+ selectable?: boolean
41
+ /** Nombre de colonnes (défaut 4) */
42
+ cols?: number
43
+ /** Nombre max de sélections en mode multiple (0 = illimité) */
44
+ maxSelected?: number
45
+ /** Coins arrondis (défaut true) */
46
+ rounded?: boolean
47
+ /** Espacement réduit entre les images */
48
+ dense?: boolean
49
+ /** Animation au survol : zoom de l'image + ombre portée */
50
+ hover?: boolean
51
+ class?: string
52
+ }
53
+
54
+ const props = withDefaults(defineProps<Props>(), {
55
+ modelValue: null,
56
+ images: () => [],
57
+ multiple: false,
58
+ labels: false,
59
+ srcKey: "src",
60
+ labelKey: "label",
61
+ descriptionKey: "description",
62
+ selectable: true,
63
+ cols: 4,
64
+ maxSelected: 0,
65
+ rounded: true,
66
+ dense: false,
67
+ hover: false,
68
+ class: "",
69
+ })
70
+
71
+ const emit = defineEmits<{
72
+ "update:modelValue": [value: GalleryValue | GalleryValue[] | null]
73
+ select: [payload: { image: GalleryValue; index: number; selected: boolean }]
74
+ }>()
75
+
76
+ interface NormalizedImage extends GalleryImage {
77
+ /** L'objet brut original (renvoyé dans le v-model et @select) */
78
+ raw: GalleryValue
79
+ }
80
+
81
+ /** Normalise chaque entrée : extrait src/label/description via les clés, garde le brut */
82
+ const items = computed<NormalizedImage[]>(() =>
83
+ props.images.map((img): NormalizedImage =>
84
+ typeof img === "string"
85
+ ? { src: img, raw: img }
86
+ : {
87
+ src: String(img[props.srcKey] ?? ""),
88
+ label: (img[props.labelKey] as string | undefined) ?? undefined,
89
+ description: (img[props.descriptionKey] as string | undefined) ?? undefined,
90
+ raw: img as unknown as GalleryValue,
91
+ },
92
+ ),
93
+ )
94
+
95
+ /** Comparaison entre une valeur du v-model et une image normalisée */
96
+ const sameValue = (a: unknown, b: NormalizedImage): boolean =>
97
+ typeof a === "string" ? a === b.src : a === b.raw
98
+
99
+ const selection = computed<GalleryValue[]>(() =>
100
+ props.multiple ? (Array.isArray(props.modelValue) ? props.modelValue : []) : [],
101
+ )
102
+
103
+ const isSelected = (item: NormalizedImage): boolean =>
104
+ props.multiple
105
+ ? selection.value.some((v) => sameValue(v, item))
106
+ : sameValue(props.modelValue, item)
107
+
108
+ const isMaxed = computed(
109
+ () =>
110
+ props.multiple &&
111
+ props.maxSelected > 0 &&
112
+ selection.value.length >= props.maxSelected,
113
+ )
114
+
115
+ const toggle = (item: NormalizedImage, index: number) => {
116
+ if (!props.selectable) return
117
+ const wasSelected = isSelected(item)
118
+ let next: GalleryValue | GalleryValue[] | null
119
+
120
+ if (props.multiple) {
121
+ const arr = [...selection.value]
122
+ const i = arr.findIndex((v) => sameValue(v, item))
123
+ if (i >= 0) arr.splice(i, 1)
124
+ else if (!isMaxed.value) arr.push(item.raw)
125
+ next = arr
126
+ } else {
127
+ next = wasSelected ? null : item.raw
128
+ }
129
+
130
+ emit("update:modelValue", next)
131
+ emit("select", { image: item.raw, index, selected: !wasSelected })
132
+ }
133
+ </script>
134
+
135
+ <template>
136
+ <div
137
+ class="q-gallery"
138
+ :class="cn(
139
+ !selectable && 'q-gallery--viewer',
140
+ dense && 'q-gallery--dense',
141
+ props.class,
142
+ )"
143
+ :style="{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }"
144
+ >
145
+ <div
146
+ v-for="(item, i) in items"
147
+ :key="`${item.src}-${i}`"
148
+ class="q-gallery__item"
149
+ :class="{
150
+ 'q-gallery__item--rounded': rounded,
151
+ 'q-gallery__item--selected': selectable && isSelected(item),
152
+ 'q-gallery__item--disabled': !isSelected(item) && isMaxed,
153
+ 'q-gallery__item--hover': hover,
154
+ }"
155
+ role="option"
156
+ :aria-selected="selectable && isSelected(item) ? 'true' : 'false'"
157
+ @click="toggle(item, i)"
158
+ >
159
+ <img
160
+ class="q-gallery__img"
161
+ :src="item.src"
162
+ :alt="item.label ?? `Image ${i + 1}`"
163
+ loading="lazy"
164
+ />
165
+ <span v-if="selectable && isSelected(item)" class="q-gallery__check" aria-hidden="true">
166
+ <Icon :icon="icons.check" />
167
+ </span>
168
+ <div v-if="labels" class="q-gallery__caption">
169
+ <div v-if="item.label" class="q-gallery__label">{{ item.label }}</div>
170
+ <div v-if="item.description" class="q-gallery__desc">{{ item.description }}</div>
171
+ </div>
172
+ </div>
173
+ </div>
174
+ </template>
175
+
176
+ <style scoped>
177
+ .q-gallery {
178
+ display: grid;
179
+ gap: 10px;
180
+ }
181
+ .q-gallery--dense {
182
+ gap: 4px;
183
+ }
184
+
185
+ .q-gallery__item {
186
+ position: relative;
187
+ overflow: hidden;
188
+ aspect-ratio: 1;
189
+ cursor: pointer;
190
+ border: 2px solid transparent;
191
+ transition:
192
+ border-color 0.15s ease,
193
+ transform 0.15s ease;
194
+ }
195
+ .q-gallery__item--rounded {
196
+ border-radius: 12px;
197
+ }
198
+ .q-gallery__item--selected {
199
+ border-color: var(--primary);
200
+ }
201
+ .q-gallery__item--disabled {
202
+ cursor: default;
203
+ opacity: 0.55;
204
+ }
205
+ .q-gallery--viewer .q-gallery__item {
206
+ cursor: default;
207
+ }
208
+
209
+ .q-gallery__img {
210
+ display: block;
211
+ width: 100%;
212
+ height: 100%;
213
+ object-fit: cover;
214
+ }
215
+
216
+ /* animation au survol : zoom doux + ombre (prop hover) */
217
+ .q-gallery__item--hover .q-gallery__img {
218
+ transition: transform 0.3s ease;
219
+ }
220
+ .q-gallery__item--hover:hover {
221
+ box-shadow: 0 10px 24px rgb(0 0 0 / 0.18);
222
+ }
223
+ .q-gallery__item--hover:hover .q-gallery__img {
224
+ transform: scale(1.06);
225
+ }
226
+ @media (prefers-reduced-motion: reduce) {
227
+ .q-gallery__item--hover .q-gallery__img {
228
+ transition: none;
229
+ }
230
+ }
231
+
232
+ /* coche de sélection (coin haut droit) */
233
+ .q-gallery__check {
234
+ position: absolute;
235
+ top: 8px;
236
+ right: 8px;
237
+ display: flex;
238
+ align-items: center;
239
+ justify-content: center;
240
+ width: 22px;
241
+ height: 22px;
242
+ border-radius: 50%;
243
+ background-color: var(--primary);
244
+ color: var(--primary-foreground);
245
+ box-shadow: 0 2px 6px rgb(0 0 0 / 0.3);
246
+ }
247
+ .q-gallery__check svg {
248
+ width: 13px;
249
+ height: 13px;
250
+ }
251
+
252
+ /* légende (nom + description) en bas de l'image */
253
+ .q-gallery__caption {
254
+ position: absolute;
255
+ left: 0;
256
+ right: 0;
257
+ bottom: 0;
258
+ padding: 10px 10px 8px;
259
+ background: linear-gradient(to top, rgb(0 0 0 / 0.72), transparent);
260
+ color: #fff;
261
+ }
262
+ .q-gallery__label {
263
+ font-size: 12.5px;
264
+ font-weight: 600;
265
+ line-height: 1.35;
266
+ overflow: hidden;
267
+ text-overflow: ellipsis;
268
+ white-space: nowrap;
269
+ }
270
+ .q-gallery__desc {
271
+ font-size: 11.5px;
272
+ line-height: 1.35;
273
+ opacity: 0.85;
274
+ display: -webkit-box;
275
+ -webkit-line-clamp: 2;
276
+ line-clamp: 2;
277
+ -webkit-box-orient: vertical;
278
+ overflow: hidden;
279
+ }
280
+ </style>
@@ -0,0 +1,54 @@
1
+ <script setup lang="ts">
2
+ // QGrid — grille CSS (12 colonnes par défaut) : <q-grid :cols="12" gap="16px">
3
+ // Breakpoints configurables via les variables CSS --q-grid-bp-* (:root par défaut).
4
+ import { computed } from "vue"
5
+ import { useGridBreakpoints } from "../lib/breakpoints"
6
+
7
+ interface Props {
8
+ /** Nombre de colonnes (défaut : 12) */
9
+ cols?: number
10
+ /** Espacement (string CSS ou nombre px) */
11
+ gap?: string | number
12
+ rowGap?: string | number
13
+ columnGap?: string | number
14
+ /** Colonnes à partir du breakpoint sm / md / lg / xl */
15
+ colsSm?: number
16
+ colsMd?: number
17
+ colsLg?: number
18
+ colsXl?: number
19
+ /** Alignement vertical des cellules */
20
+ align?: "start" | "center" | "end" | "stretch"
21
+ }
22
+
23
+ const props = withDefaults(defineProps<Props>(), {
24
+ cols: 12,
25
+ gap: "16px",
26
+ align: "stretch",
27
+ })
28
+
29
+ const bp = useGridBreakpoints()
30
+
31
+ const px = (v: string | number | undefined) =>
32
+ v === undefined ? undefined : typeof v === "number" ? `${v}px` : v
33
+
34
+ const style = computed<Record<string, string>>(() => ({
35
+ "--q-grid-cols": String(props.cols),
36
+ "--q-grid-gap": px(props.gap)!,
37
+ ...(props.rowGap !== undefined ? { "--q-grid-row-gap": px(props.rowGap)! } : {}),
38
+ ...(props.columnGap !== undefined ? { "--q-grid-column-gap": px(props.columnGap)! } : {}),
39
+ alignItems: props.align,
40
+ }))
41
+
42
+ const classes = computed(() => ({
43
+ ...(bp.value.sm && props.colsSm ? { [`q-grid--cols-sm-${props.colsSm}`]: true } : {}),
44
+ ...(bp.value.md && props.colsMd ? { [`q-grid--cols-md-${props.colsMd}`]: true } : {}),
45
+ ...(bp.value.lg && props.colsLg ? { [`q-grid--cols-lg-${props.colsLg}`]: true } : {}),
46
+ ...(bp.value.xl && props.colsXl ? { [`q-grid--cols-xl-${props.colsXl}`]: true } : {}),
47
+ }))
48
+ </script>
49
+
50
+ <template>
51
+ <div class="q-grid" :class="classes" :style="style">
52
+ <slot />
53
+ </div>
54
+ </template>