@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.
- package/README.md +15 -0
- package/components/QAccordion.vue +68 -0
- package/components/QAccordionContent.vue +62 -0
- package/components/QAccordionItem.vue +24 -0
- package/components/QAccordionTrigger.vue +51 -0
- package/components/QActionSheet.vue +159 -0
- package/components/QApp.vue +8 -0
- package/components/QAutocomplete.vue +316 -0
- package/components/QAvatar.vue +92 -0
- package/components/QBackHeader.vue +69 -0
- package/components/QBackTop.vue +125 -0
- package/components/QBadge.vue +55 -0
- package/components/QBottomSheet.vue +177 -0
- package/components/QBottomSheetFooter.vue +9 -0
- package/components/QBottomSheetHeader.vue +35 -0
- package/components/QBottomSheetProvider.vue +62 -0
- package/components/QBottomSheetTrigger.vue +18 -0
- package/components/QBtn.vue +144 -0
- package/components/QBtnGroup.vue +31 -0
- package/components/QBubble.vue +36 -0
- package/components/QBubbleContent.vue +28 -0
- package/components/QBubbleGroup.vue +25 -0
- package/components/QBubbleReactions.vue +40 -0
- package/components/QCard.vue +63 -0
- package/components/QCardActions.vue +27 -0
- package/components/QCardContent.vue +29 -0
- package/components/QCarousel.vue +115 -0
- package/components/QCarouselContent.vue +23 -0
- package/components/QCarouselItem.vue +20 -0
- package/components/QCarouselNav.vue +51 -0
- package/components/QCheckbox.vue +146 -0
- package/components/QChip.vue +91 -0
- package/components/QCircularProgress.vue +122 -0
- package/components/QCol.vue +52 -0
- package/components/QCollapse.vue +141 -0
- package/components/QConfigProvider.vue +164 -0
- package/components/QContainer.vue +201 -0
- package/components/QCountDown.vue +164 -0
- package/components/QCountryPicker.vue +593 -0
- package/components/QDataGrid.vue +491 -0
- package/components/QDatePicker.vue +250 -0
- package/components/QDialog.vue +163 -0
- package/components/QDialogFooter.vue +9 -0
- package/components/QDialogHeader.vue +41 -0
- package/components/QDialogProvider.vue +65 -0
- package/components/QDialogTrigger.vue +18 -0
- package/components/QFab.vue +80 -0
- package/components/QFabAction.vue +48 -0
- package/components/QFilePicker.vue +294 -0
- package/components/QFooter.vue +72 -0
- package/components/QGallery.vue +280 -0
- package/components/QGrid.vue +54 -0
- package/components/QHeader.vue +38 -0
- package/components/QIcon.vue +53 -0
- package/components/QImagePicker.vue +248 -0
- package/components/QImagePreview.vue +225 -0
- package/components/QImagePreviewProvider.vue +35 -0
- package/components/QImg.vue +142 -0
- package/components/QInfiniteScroll.vue +109 -0
- package/components/QInnerLoading.vue +67 -0
- package/components/QInput.vue +289 -0
- package/components/QInputOtp.vue +238 -0
- package/components/QInputPassword.vue +67 -0
- package/components/QInputTag.vue +281 -0
- package/components/QIntersection.vue +119 -0
- package/components/QItem.vue +60 -0
- package/components/QItemSection.vue +47 -0
- package/components/QLinearProgress.vue +94 -0
- package/components/QList.vue +39 -0
- package/components/QLoading.vue +77 -0
- package/components/QLoadingProvider.vue +41 -0
- package/components/QMarquee.vue +56 -0
- package/components/QMessageScroller.vue +10 -0
- package/components/QMessageScrollerButton.vue +48 -0
- package/components/QMessageScrollerContent.vue +31 -0
- package/components/QMessageScrollerItem.vue +49 -0
- package/components/QMessageScrollerProvider.vue +198 -0
- package/components/QMessageScrollerViewport.vue +109 -0
- package/components/QNavMenu.vue +75 -0
- package/components/QNavMenuContent.vue +10 -0
- package/components/QNavMenuItem.vue +60 -0
- package/components/QNavMenuTrigger.vue +96 -0
- package/components/QNotifyProvider.vue +33 -0
- package/components/QNotifyToast.vue +60 -0
- package/components/QPage.vue +56 -0
- package/components/QPagination.vue +195 -0
- package/components/QParallax.vue +124 -0
- package/components/QPullToRefresh.vue +138 -0
- package/components/QRadio.vue +99 -0
- package/components/QRating.vue +105 -0
- package/components/QReorder.vue +191 -0
- package/components/QRollingText.vue +225 -0
- package/components/QRow.vue +23 -0
- package/components/QSafeArea.vue +39 -0
- package/components/QScrollArea.vue +118 -0
- package/components/QSelect.vue +698 -0
- package/components/QSeparator.vue +39 -0
- package/components/QSidebar.vue +153 -0
- package/components/QSidebarContent.vue +14 -0
- package/components/QSidebarFooter.vue +14 -0
- package/components/QSidebarHeader.vue +14 -0
- package/components/QSidebarMenu.vue +9 -0
- package/components/QSidebarMenuButton.vue +59 -0
- package/components/QSidebarMenuItem.vue +9 -0
- package/components/QSidebarTrigger.vue +32 -0
- package/components/QSkeleton.vue +95 -0
- package/components/QSlider.vue +205 -0
- package/components/QSpace.vue +10 -0
- package/components/QSpinner.vue +172 -0
- package/components/QSplitter.vue +145 -0
- package/components/QSticky.vue +181 -0
- package/components/QSwipeCell.vue +217 -0
- package/components/QSwiper.vue +102 -0
- package/components/QSwiperSlide.vue +19 -0
- package/components/QSyntax.vue +171 -0
- package/components/QTab.vue +175 -0
- package/components/QTabPanel.vue +50 -0
- package/components/QTabPanels.vue +106 -0
- package/components/QTable.vue +575 -0
- package/components/QTabs.vue +188 -0
- package/components/QText.vue +316 -0
- package/components/QTextCaption.vue +122 -0
- package/components/QToolbar.vue +28 -0
- package/components/QTooltip.vue +313 -0
- package/components/QUploader.vue +473 -0
- package/components/QVideo.vue +289 -0
- package/components/QVirtualScroll.vue +190 -0
- package/components/internal/QDateCalendar.vue +153 -0
- package/index.ts +151 -0
- package/lib/breakpoints.ts +44 -0
- package/lib/colors.ts +24 -0
- package/lib/config.ts +34 -0
- package/lib/countries.ts +261 -0
- package/lib/formula.ts +630 -0
- package/lib/icons.ts +38 -0
- package/lib/loading.ts +75 -0
- package/lib/overlayBack.ts +124 -0
- package/lib/platform.ts +146 -0
- package/lib/primitive.ts +25 -0
- package/lib/q.ts +287 -0
- package/lib/screen.ts +105 -0
- package/lib/search.ts +35 -0
- package/lib/themeVars.ts +22 -0
- package/lib/useComponentProps.ts +54 -0
- package/lib/utils.ts +7 -0
- package/module.ts +89 -0
- package/package.json +41 -0
- package/scripts/generate-exports.ts +56 -0
- package/styles/main.css +7627 -0
- 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>
|