@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,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>
|