@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,316 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// QAutocomplete — Combobox type shadcn-vue, API Quasar (QSelect use-input) :
|
|
3
|
+
// <q-autocomplete v-model="val" :options="opts" option-value="id" option-label="name" clearable dense outlined />
|
|
4
|
+
// Filtre client par défaut ; @filter → mode serveur (le parent met à jour :options).
|
|
5
|
+
import { computed, getCurrentInstance, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
|
|
6
|
+
import { Icon } from "@iconify/vue"
|
|
7
|
+
import { icons } from "../lib/icons"
|
|
8
|
+
import { cn } from "../lib/utils"
|
|
9
|
+
import { radiusStyle, useRadius } from "../lib/useComponentProps"
|
|
10
|
+
import type { RadiusProp } from "../lib/useComponentProps"
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
/** Valeur sélectionnée */
|
|
14
|
+
modelValue?: any
|
|
15
|
+
/** Options candidates */
|
|
16
|
+
options?: any[]
|
|
17
|
+
/** Propriété ou fonction donnant la valeur d'une option (défaut "value") */
|
|
18
|
+
optionValue?: string | ((opt: any) => any)
|
|
19
|
+
/** Propriété ou fonction donnant le label d'une option (défaut "label") */
|
|
20
|
+
optionLabel?: string | ((opt: any) => any)
|
|
21
|
+
/** Texte saisi (v-model:input-value) */
|
|
22
|
+
inputValue?: string
|
|
23
|
+
label?: string
|
|
24
|
+
stackLabel?: boolean
|
|
25
|
+
hint?: string
|
|
26
|
+
error?: boolean
|
|
27
|
+
errorMessage?: string
|
|
28
|
+
placeholder?: string
|
|
29
|
+
/** Variantes de champ (mêmes styles que QInput) */
|
|
30
|
+
outlined?: boolean
|
|
31
|
+
filled?: boolean
|
|
32
|
+
borderless?: boolean
|
|
33
|
+
/** Coins arrondis : true = pilule, ou échelle xs|sm|md|lg (none = carré) */
|
|
34
|
+
radius?: RadiusProp
|
|
35
|
+
dense?: boolean
|
|
36
|
+
clearable?: boolean
|
|
37
|
+
disable?: boolean
|
|
38
|
+
readonly?: boolean
|
|
39
|
+
loading?: boolean
|
|
40
|
+
noOptionsLabel?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
44
|
+
options: () => [],
|
|
45
|
+
optionValue: "value",
|
|
46
|
+
optionLabel: "label",
|
|
47
|
+
stackLabel: false,
|
|
48
|
+
error: false,
|
|
49
|
+
outlined: false,
|
|
50
|
+
filled: false,
|
|
51
|
+
borderless: false,
|
|
52
|
+
dense: false,
|
|
53
|
+
clearable: false,
|
|
54
|
+
disable: false,
|
|
55
|
+
readonly: false,
|
|
56
|
+
loading: false,
|
|
57
|
+
noOptionsLabel: "No options to display",
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const emit = defineEmits<{
|
|
61
|
+
"update:modelValue": [value: any]
|
|
62
|
+
"update:inputValue": [value: string]
|
|
63
|
+
filter: [value: string, update: (fn: () => void) => void]
|
|
64
|
+
focus: [event: FocusEvent]
|
|
65
|
+
blur: [event: FocusEvent]
|
|
66
|
+
clear: []
|
|
67
|
+
}>()
|
|
68
|
+
|
|
69
|
+
defineSlots<{
|
|
70
|
+
default?: (props: Record<string, any>) => any // option : { option, index, selected, active }
|
|
71
|
+
noOptions?: () => any
|
|
72
|
+
error?: () => any
|
|
73
|
+
hint?: () => any
|
|
74
|
+
}>()
|
|
75
|
+
|
|
76
|
+
const instance = getCurrentInstance()
|
|
77
|
+
const hasFilter = computed(() => !!instance?.vnode.props?.onFilter)
|
|
78
|
+
|
|
79
|
+
const rootEl = ref<HTMLElement | null>(null)
|
|
80
|
+
const popupRef = ref<HTMLElement | null>(null)
|
|
81
|
+
const focused = ref(false)
|
|
82
|
+
const open = ref(false)
|
|
83
|
+
const query = ref(props.inputValue ?? "")
|
|
84
|
+
const activeIndex = ref(0)
|
|
85
|
+
|
|
86
|
+
// — Valeurs des options —
|
|
87
|
+
const getValue = (opt: any): any =>
|
|
88
|
+
typeof props.optionValue === "function" ? props.optionValue(opt) : opt?.[props.optionValue]
|
|
89
|
+
|
|
90
|
+
const getLabel = (opt: any): string => {
|
|
91
|
+
const v = typeof props.optionLabel === "function" ? props.optionLabel(opt) : opt?.[props.optionLabel]
|
|
92
|
+
return v === undefined || v === null ? "" : String(v)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const selectedLabel = computed(() => {
|
|
96
|
+
if (props.modelValue === undefined || props.modelValue === null) return ""
|
|
97
|
+
const opt = props.options.find((o) => getValue(o) === props.modelValue)
|
|
98
|
+
return opt ? getLabel(opt) : String(props.modelValue)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// — Filtre client (sauté en mode serveur @filter) —
|
|
102
|
+
const filtered = computed(() => {
|
|
103
|
+
if (hasFilter.value) return props.options
|
|
104
|
+
const q = query.value.trim().toLowerCase()
|
|
105
|
+
if (!q) return props.options
|
|
106
|
+
return props.options.filter((o) => getLabel(o).toLowerCase().includes(q))
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// — Affichage du champ —
|
|
110
|
+
const inputDisplay = computed(() => (query.value !== "" ? query.value : selectedLabel.value))
|
|
111
|
+
|
|
112
|
+
const floatActive = computed(
|
|
113
|
+
() => focused.value || query.value !== "" || props.modelValue !== undefined || props.stackLabel,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
const fieldClasses = computed(() =>
|
|
117
|
+
cn(
|
|
118
|
+
"q-autocomplete q-input",
|
|
119
|
+
props.outlined && "q-field--outlined",
|
|
120
|
+
props.filled && "q-field--filled",
|
|
121
|
+
props.borderless && "q-field--borderless",
|
|
122
|
+
effectiveRadius.value === true && "q-field--rounded",
|
|
123
|
+
props.dense && "q-field--dense",
|
|
124
|
+
props.error && "q-field--error",
|
|
125
|
+
props.disable && "q-field--disabled",
|
|
126
|
+
floatActive.value && "q-field--float",
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
// radius : prop explicite > composantProps.QAutocomplete > composantProps.default
|
|
131
|
+
const effectiveRadius = useRadius("QAutocomplete", () => props.radius)
|
|
132
|
+
const roundedStyle = computed(() => radiusStyle(effectiveRadius.value))
|
|
133
|
+
|
|
134
|
+
// — Interactions —
|
|
135
|
+
const openPopup = () => {
|
|
136
|
+
if (props.disable || props.readonly) return
|
|
137
|
+
activeIndex.value = 0
|
|
138
|
+
open.value = true
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const onInput = (e: Event) => {
|
|
142
|
+
const v = (e.target as HTMLInputElement).value
|
|
143
|
+
query.value = v
|
|
144
|
+
if (v !== selectedLabel.value) emit("update:modelValue", undefined)
|
|
145
|
+
emit("update:inputValue", v)
|
|
146
|
+
activeIndex.value = 0
|
|
147
|
+
open.value = true
|
|
148
|
+
if (hasFilter.value) emit("filter", v, (fn) => fn())
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const onSelect = (opt: any) => {
|
|
152
|
+
const label = getLabel(opt)
|
|
153
|
+
emit("update:modelValue", getValue(opt))
|
|
154
|
+
emit("update:inputValue", label)
|
|
155
|
+
query.value = label
|
|
156
|
+
open.value = false
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const onClear = () => {
|
|
160
|
+
emit("update:modelValue", undefined)
|
|
161
|
+
emit("update:inputValue", "")
|
|
162
|
+
query.value = ""
|
|
163
|
+
emit("clear")
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const isSelected = (opt: any) => getValue(opt) === props.modelValue
|
|
167
|
+
|
|
168
|
+
// À la perte de focus : si le texte saisi ne correspond exactement à aucune
|
|
169
|
+
// option (insensible à la casse), on le supprime — le champ redevient vide.
|
|
170
|
+
const commit = () => {
|
|
171
|
+
const q = query.value.trim()
|
|
172
|
+
if (!q) return
|
|
173
|
+
const label = q.toLowerCase()
|
|
174
|
+
const matches = props.options.some((o) => getLabel(o).toLowerCase() === label)
|
|
175
|
+
if (!matches) {
|
|
176
|
+
query.value = ""
|
|
177
|
+
emit("update:inputValue", "")
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const onFocus = (e: FocusEvent) => {
|
|
182
|
+
focused.value = true
|
|
183
|
+
openPopup()
|
|
184
|
+
emit("focus", e)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const onBlur = (e: FocusEvent) => {
|
|
188
|
+
focused.value = false
|
|
189
|
+
open.value = false
|
|
190
|
+
commit()
|
|
191
|
+
emit("blur", e)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const onKeydown = (e: KeyboardEvent) => {
|
|
195
|
+
if (!open.value) {
|
|
196
|
+
if (e.key === "ArrowDown" || e.key === "Enter") {
|
|
197
|
+
e.preventDefault()
|
|
198
|
+
openPopup()
|
|
199
|
+
}
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
if (e.key === "ArrowDown") {
|
|
203
|
+
e.preventDefault()
|
|
204
|
+
if (filtered.value.length) activeIndex.value = (activeIndex.value + 1) % filtered.value.length
|
|
205
|
+
}
|
|
206
|
+
else if (e.key === "ArrowUp") {
|
|
207
|
+
e.preventDefault()
|
|
208
|
+
if (filtered.value.length) {
|
|
209
|
+
activeIndex.value = (activeIndex.value - 1 + filtered.value.length) % filtered.value.length
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
else if (e.key === "Enter") {
|
|
213
|
+
e.preventDefault()
|
|
214
|
+
const opt = filtered.value[activeIndex.value]
|
|
215
|
+
if (opt) onSelect(opt)
|
|
216
|
+
}
|
|
217
|
+
else if (e.key === "Escape") {
|
|
218
|
+
e.preventDefault()
|
|
219
|
+
open.value = false
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Item actif visible au scroll
|
|
224
|
+
watch(activeIndex, async () => {
|
|
225
|
+
await nextTick()
|
|
226
|
+
const items = popupRef.value?.querySelectorAll(".q-autocomplete__item")
|
|
227
|
+
const el = items?.[activeIndex.value] as HTMLElement | undefined
|
|
228
|
+
el?.scrollIntoView({ block: "nearest" })
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
// Fermeture au clic extérieur
|
|
232
|
+
const onDocMousedown = (e: MouseEvent) => {
|
|
233
|
+
if (rootEl.value && !rootEl.value.contains(e.target as Node)) open.value = false
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
onMounted(() => {
|
|
237
|
+
if (typeof document !== "undefined") document.addEventListener("mousedown", onDocMousedown)
|
|
238
|
+
})
|
|
239
|
+
onBeforeUnmount(() => {
|
|
240
|
+
if (typeof document !== "undefined") document.removeEventListener("mousedown", onDocMousedown)
|
|
241
|
+
})
|
|
242
|
+
</script>
|
|
243
|
+
|
|
244
|
+
<template>
|
|
245
|
+
<div ref="rootEl" class="q-autocomplete" :class="fieldClasses" :style="roundedStyle">
|
|
246
|
+
<label v-if="label" class="q-field__label-stack">{{ label }}</label>
|
|
247
|
+
<div class="q-field__control">
|
|
248
|
+
<input
|
|
249
|
+
class="q-field__native"
|
|
250
|
+
:value="inputDisplay"
|
|
251
|
+
:placeholder="placeholder"
|
|
252
|
+
:disabled="disable || undefined"
|
|
253
|
+
:readonly="readonly || undefined"
|
|
254
|
+
role="combobox"
|
|
255
|
+
:aria-expanded="open ? 'true' : 'false'"
|
|
256
|
+
aria-autocomplete="list"
|
|
257
|
+
aria-haspopup="listbox"
|
|
258
|
+
@input="onInput"
|
|
259
|
+
@focus="onFocus"
|
|
260
|
+
@blur="onBlur"
|
|
261
|
+
@keydown="onKeydown"
|
|
262
|
+
/>
|
|
263
|
+
<span v-if="loading" class="q-spinner-ring q-autocomplete__spinner" aria-hidden="true" />
|
|
264
|
+
<button
|
|
265
|
+
v-if="clearable && modelValue !== undefined && !disable"
|
|
266
|
+
class="q-field__clear"
|
|
267
|
+
type="button"
|
|
268
|
+
aria-label="Effacer"
|
|
269
|
+
@click="onClear"
|
|
270
|
+
>
|
|
271
|
+
<Icon :icon="icons.x" aria-hidden="true" />
|
|
272
|
+
</button>
|
|
273
|
+
</div>
|
|
274
|
+
<div v-if="error" class="q-field__bottom">
|
|
275
|
+
<div class="q-field__error">
|
|
276
|
+
<slot name="error">{{ errorMessage }}</slot>
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
<div v-else-if="hint || $slots.hint" class="q-field__bottom">
|
|
280
|
+
<div class="q-field__hint">
|
|
281
|
+
<slot name="hint">{{ hint }}</slot>
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
<div
|
|
286
|
+
v-if="open"
|
|
287
|
+
ref="popupRef"
|
|
288
|
+
class="q-autocomplete__popup"
|
|
289
|
+
role="listbox"
|
|
290
|
+
@mousedown.prevent
|
|
291
|
+
>
|
|
292
|
+
<template v-if="filtered.length">
|
|
293
|
+
<div
|
|
294
|
+
v-for="(opt, i) in filtered"
|
|
295
|
+
:key="String(getValue(opt))"
|
|
296
|
+
role="option"
|
|
297
|
+
:aria-selected="i === activeIndex ? 'true' : 'false'"
|
|
298
|
+
class="q-autocomplete__item"
|
|
299
|
+
:class="{
|
|
300
|
+
'q-autocomplete__item--active': i === activeIndex,
|
|
301
|
+
'q-autocomplete__item--selected': isSelected(opt),
|
|
302
|
+
}"
|
|
303
|
+
@mouseenter="activeIndex = i"
|
|
304
|
+
@mousedown.prevent="onSelect(opt)"
|
|
305
|
+
>
|
|
306
|
+
<slot :option="opt" :index="i" :selected="isSelected(opt)" :active="i === activeIndex">
|
|
307
|
+
<span class="q-autocomplete__label">{{ getLabel(opt) }}</span>
|
|
308
|
+
</slot>
|
|
309
|
+
</div>
|
|
310
|
+
</template>
|
|
311
|
+
<div v-else class="q-autocomplete__empty">
|
|
312
|
+
<slot name="noOptions">{{ noOptionsLabel }}</slot>
|
|
313
|
+
</div>
|
|
314
|
+
</div>
|
|
315
|
+
</div>
|
|
316
|
+
</template>
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// QAvatar — avatar : <q-avatar src="…" alt="Jean Dupont" color="primary" size="lg" />
|
|
3
|
+
// Image avec fallback automatique (initiales depuis alt, icône, ou slot) en cas d'erreur.
|
|
4
|
+
import { computed, ref } from "vue"
|
|
5
|
+
import { Icon } from "@iconify/vue"
|
|
6
|
+
import { colorValue, foregroundFor } from "../lib/colors"
|
|
7
|
+
|
|
8
|
+
const SIZE_MAP: Record<string, string> = { xs: "24px", sm: "32px", md: "48px", lg: "64px", xl: "96px" }
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
/** URL de l'image */
|
|
12
|
+
src?: string
|
|
13
|
+
/** Texte alternatif (utilisé pour les initiales du fallback) */
|
|
14
|
+
alt?: string
|
|
15
|
+
/** Icône Iconify affichée en fallback (ex. : "lucide:user") */
|
|
16
|
+
icon?: string
|
|
17
|
+
/** Taille : xs | sm | md | lg | xl ou valeur CSS */
|
|
18
|
+
size?: string
|
|
19
|
+
/** Taille de la police (défaut : 40% de la taille) */
|
|
20
|
+
fontSize?: string
|
|
21
|
+
/** Couleur de fond (token ou hex) */
|
|
22
|
+
color?: string
|
|
23
|
+
/** Couleur du texte/icône */
|
|
24
|
+
textColor?: string
|
|
25
|
+
/** Coins légèrement arrondis (au lieu du cercle) */
|
|
26
|
+
rounded?: boolean
|
|
27
|
+
/** Coins droits */
|
|
28
|
+
square?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
32
|
+
size: "md",
|
|
33
|
+
color: "",
|
|
34
|
+
textColor: "",
|
|
35
|
+
rounded: false,
|
|
36
|
+
square: false,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const imgOk = ref(true)
|
|
40
|
+
|
|
41
|
+
const avatarSize = computed(() => SIZE_MAP[props.size] ?? props.size)
|
|
42
|
+
|
|
43
|
+
const avatarStyle = computed<Record<string, string>>(() => {
|
|
44
|
+
const style: Record<string, string> = {
|
|
45
|
+
"--q-avatar-size": avatarSize.value,
|
|
46
|
+
}
|
|
47
|
+
if (props.color) {
|
|
48
|
+
style.backgroundColor = colorValue(props.color)
|
|
49
|
+
style.color = props.textColor ? colorValue(props.textColor) : foregroundFor(props.color)
|
|
50
|
+
}
|
|
51
|
+
if (props.fontSize) style.fontSize = props.fontSize
|
|
52
|
+
return style
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// Initiales depuis alt ("Jean Dupont" → "JD")
|
|
56
|
+
const initials = computed(() => {
|
|
57
|
+
if (!props.alt) return ""
|
|
58
|
+
const words = props.alt.trim().split(/\s+/).filter(Boolean)
|
|
59
|
+
if (words.length === 0) return ""
|
|
60
|
+
if (words.length === 1) return words[0]!.slice(0, 2).toUpperCase()
|
|
61
|
+
return (words[0]![0]! + words[words.length - 1]![0]!).toUpperCase()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const onError = () => {
|
|
65
|
+
imgOk.value = false
|
|
66
|
+
}
|
|
67
|
+
</script>
|
|
68
|
+
|
|
69
|
+
<template>
|
|
70
|
+
<div
|
|
71
|
+
class="q-avatar"
|
|
72
|
+
:class="{
|
|
73
|
+
'q-avatar--rounded': rounded,
|
|
74
|
+
'q-avatar--square': square,
|
|
75
|
+
}"
|
|
76
|
+
:style="avatarStyle"
|
|
77
|
+
role="img"
|
|
78
|
+
:aria-label="alt || undefined"
|
|
79
|
+
>
|
|
80
|
+
<img
|
|
81
|
+
v-if="src && imgOk"
|
|
82
|
+
class="q-avatar__img"
|
|
83
|
+
:src="src"
|
|
84
|
+
:alt="alt || ''"
|
|
85
|
+
@error="onError"
|
|
86
|
+
/>
|
|
87
|
+
<div v-else class="q-avatar__fallback">
|
|
88
|
+
<Icon :icon="icon" v-if="icon" aria-hidden="true" />
|
|
89
|
+
<slot v-else>{{ initials }}</slot>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
</template>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// QBackHeader — barre de retour : <q-back-header title="Détails" fixed @back="router.back()" />
|
|
3
|
+
// Bouton retour + titre + actions à droite ; safe-area top intégrée.
|
|
4
|
+
import { computed } from "vue"
|
|
5
|
+
import { Icon } from "@iconify/vue"
|
|
6
|
+
import { icons } from "../lib/icons"
|
|
7
|
+
import { cn } from "../lib/utils"
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
/** Titre de la page */
|
|
11
|
+
title?: string
|
|
12
|
+
/** Affiche le bouton retour */
|
|
13
|
+
showBack?: boolean
|
|
14
|
+
/** Icône Iconify du bouton retour (défaut : lucide:chevron-left) */
|
|
15
|
+
backIcon?: string
|
|
16
|
+
/** Libellé accessible du bouton retour */
|
|
17
|
+
backLabel?: string
|
|
18
|
+
/** Barre fixée en haut de l'écran */
|
|
19
|
+
fixed?: boolean
|
|
20
|
+
/** Thème sombre */
|
|
21
|
+
dark?: boolean
|
|
22
|
+
/** Fond translucide (frosted glass) : true = 70%, ou valeur % */
|
|
23
|
+
translucent?: boolean | number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
27
|
+
showBack: true,
|
|
28
|
+
backLabel: "Retour",
|
|
29
|
+
fixed: false,
|
|
30
|
+
dark: false,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const emit = defineEmits<{ back: [] }>()
|
|
34
|
+
|
|
35
|
+
const translucentStyle = computed<Record<string, string> | undefined>(() =>
|
|
36
|
+
props.translucent === true || typeof props.translucent === "number"
|
|
37
|
+
? { "--q-translucent-opacity": `${typeof props.translucent === "number" ? props.translucent : 70}%` }
|
|
38
|
+
: undefined,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
const headerClasses = computed(() =>
|
|
42
|
+
cn(
|
|
43
|
+
"q-back-header",
|
|
44
|
+
props.fixed && "q-back-header--fixed",
|
|
45
|
+
props.dark && "q-back-header--dark",
|
|
46
|
+
(props.translucent === true || typeof props.translucent === "number") && "q-back-header--translucent",
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<template>
|
|
52
|
+
<header class="q-back-header" :class="headerClasses" :style="translucentStyle">
|
|
53
|
+
<button
|
|
54
|
+
v-if="showBack"
|
|
55
|
+
type="button"
|
|
56
|
+
class="q-back-header__back"
|
|
57
|
+
:aria-label="backLabel"
|
|
58
|
+
@click="emit('back')"
|
|
59
|
+
>
|
|
60
|
+
<Icon :icon="backIcon || icons.chevronLeft" aria-hidden="true" />
|
|
61
|
+
</button>
|
|
62
|
+
<div class="q-back-header__title">
|
|
63
|
+
<slot name="title">{{ title }}</slot>
|
|
64
|
+
</div>
|
|
65
|
+
<div class="q-back-header__actions">
|
|
66
|
+
<slot />
|
|
67
|
+
</div>
|
|
68
|
+
</header>
|
|
69
|
+
</template>
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// QBackTop — API Quasar : <q-back-top :offset="300" position="bottom-right" color="primary" />
|
|
3
|
+
// Bouton collé au coin de son conteneur scrollable (position: sticky dans la div parent) :
|
|
4
|
+
// il reste visible pendant le scroll — contrairement à position:fixed, aucun ancêtre
|
|
5
|
+
// (transform, overflow…) ne peut le faire disparaître.
|
|
6
|
+
import { computed, onBeforeUnmount, onMounted, ref } from "vue"
|
|
7
|
+
import { Icon } from "@iconify/vue"
|
|
8
|
+
import { icons } from "../lib/icons"
|
|
9
|
+
import { colorValue, foregroundFor } from "../lib/colors"
|
|
10
|
+
|
|
11
|
+
type BackTopPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right"
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
/** Seuil de scroll (px) avant affichage (défaut : 300) */
|
|
15
|
+
offset?: number
|
|
16
|
+
/** Coin du conteneur parent où le bouton est collé */
|
|
17
|
+
position?: BackTopPosition
|
|
18
|
+
/** Icône Iconify (défaut : lucide:arrow-up) */
|
|
19
|
+
icon?: string
|
|
20
|
+
/** Couleur du bouton (token ou hex) */
|
|
21
|
+
color?: string
|
|
22
|
+
disable?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
26
|
+
offset: 300,
|
|
27
|
+
position: "bottom-right",
|
|
28
|
+
icon: icons.arrowUp,
|
|
29
|
+
color: "primary",
|
|
30
|
+
disable: false,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const wrapEl = ref<HTMLElement | null>(null)
|
|
34
|
+
const visible = ref(false)
|
|
35
|
+
const scroller = ref<HTMLElement | Window | null>(null)
|
|
36
|
+
|
|
37
|
+
const findScrollContainer = (el: HTMLElement | null): HTMLElement | Window | null => {
|
|
38
|
+
let node: HTMLElement | null = el?.parentElement ?? null
|
|
39
|
+
while (node) {
|
|
40
|
+
if (/(auto|scroll)/.test(getComputedStyle(node).overflowY)) return node
|
|
41
|
+
node = node.parentElement
|
|
42
|
+
}
|
|
43
|
+
return typeof window !== "undefined" ? window : null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const update = () => {
|
|
47
|
+
const sc = scroller.value
|
|
48
|
+
if (!sc) return
|
|
49
|
+
const top = sc === window ? window.scrollY : (sc as HTMLElement).scrollTop
|
|
50
|
+
visible.value = top > props.offset
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const onClick = () => {
|
|
54
|
+
if (props.disable) return
|
|
55
|
+
const sc = scroller.value
|
|
56
|
+
if (!sc) return
|
|
57
|
+
if (sc === window) window.scrollTo({ top: 0, behavior: "smooth" })
|
|
58
|
+
else (sc as HTMLElement).scrollTo({ top: 0, behavior: "smooth" })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
onMounted(() => {
|
|
62
|
+
scroller.value = findScrollContainer(wrapEl.value)
|
|
63
|
+
if (typeof window !== "undefined") {
|
|
64
|
+
window.addEventListener("scroll", update, true)
|
|
65
|
+
window.addEventListener("resize", update)
|
|
66
|
+
}
|
|
67
|
+
update()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
onBeforeUnmount(() => {
|
|
71
|
+
if (typeof window !== "undefined") {
|
|
72
|
+
window.removeEventListener("scroll", update, true)
|
|
73
|
+
window.removeEventListener("resize", update)
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const btnStyle = computed<Record<string, string>>(() => ({
|
|
78
|
+
backgroundColor: colorValue(props.color),
|
|
79
|
+
color: foregroundFor(props.color),
|
|
80
|
+
}))
|
|
81
|
+
|
|
82
|
+
const isTop = computed(() => props.position === "top-left" || props.position === "top-right")
|
|
83
|
+
</script>
|
|
84
|
+
|
|
85
|
+
<template>
|
|
86
|
+
<!-- Positions hautes : le bouton est rendu avant le contenu (sticky top) -->
|
|
87
|
+
<template v-if="isTop">
|
|
88
|
+
<div
|
|
89
|
+
ref="wrapEl"
|
|
90
|
+
class="q-back-top"
|
|
91
|
+
:class="[`q-back-top--${position}`, { 'q-back-top--visible': visible && !disable }]"
|
|
92
|
+
>
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
class="q-back-top__btn"
|
|
96
|
+
:style="btnStyle"
|
|
97
|
+
:aria-label="'Back to top'"
|
|
98
|
+
@click="onClick"
|
|
99
|
+
>
|
|
100
|
+
<Icon :icon="icon" aria-hidden="true" />
|
|
101
|
+
</button>
|
|
102
|
+
</div>
|
|
103
|
+
<slot />
|
|
104
|
+
</template>
|
|
105
|
+
|
|
106
|
+
<!-- Positions basses : le bouton est rendu après le contenu (sticky bottom) -->
|
|
107
|
+
<template v-else>
|
|
108
|
+
<slot />
|
|
109
|
+
<div
|
|
110
|
+
ref="wrapEl"
|
|
111
|
+
class="q-back-top"
|
|
112
|
+
:class="[`q-back-top--${position}`, { 'q-back-top--visible': visible && !disable }]"
|
|
113
|
+
>
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
class="q-back-top__btn"
|
|
117
|
+
:style="btnStyle"
|
|
118
|
+
:aria-label="'Back to top'"
|
|
119
|
+
@click="onClick"
|
|
120
|
+
>
|
|
121
|
+
<Icon :icon="icon" aria-hidden="true" />
|
|
122
|
+
</button>
|
|
123
|
+
</div>
|
|
124
|
+
</template>
|
|
125
|
+
</template>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// QBadge — API Quasar : <q-badge label="3" color="negative" floating multi-line transparent outline>
|
|
3
|
+
import { computed } from "vue"
|
|
4
|
+
import { cn } from "../lib/utils"
|
|
5
|
+
import { colorValue, foregroundFor } from "../lib/colors"
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
/** Texte ou nombre */
|
|
9
|
+
label?: string | number
|
|
10
|
+
/** Couleur de fond (token ou hex) */
|
|
11
|
+
color?: string
|
|
12
|
+
/** Couleur du texte (token ou hex) */
|
|
13
|
+
textColor?: string
|
|
14
|
+
/** Positionné en absolu (coin supérieur droit du parent, qui doit être relative) */
|
|
15
|
+
floating?: boolean
|
|
16
|
+
/** Retour à la ligne (bordure carrée) */
|
|
17
|
+
multiLine?: boolean
|
|
18
|
+
/** Légèrement transparent */
|
|
19
|
+
transparent?: boolean
|
|
20
|
+
/** Contour seul */
|
|
21
|
+
outline?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
25
|
+
color: "primary",
|
|
26
|
+
textColor: "",
|
|
27
|
+
floating: false,
|
|
28
|
+
multiLine: false,
|
|
29
|
+
transparent: false,
|
|
30
|
+
outline: false,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const badgeStyle = computed<Record<string, string>>(() => {
|
|
34
|
+
const style: Record<string, string> = {}
|
|
35
|
+
style["--q-badge-bg"] = colorValue(props.color)
|
|
36
|
+
style["--q-badge-fg"] = props.textColor ? colorValue(props.textColor) : foregroundFor(props.color)
|
|
37
|
+
return style
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const badgeClasses = computed(() =>
|
|
41
|
+
cn(
|
|
42
|
+
"q-badge",
|
|
43
|
+
props.floating && "q-badge--floating",
|
|
44
|
+
props.multiLine && "q-badge--multi-line",
|
|
45
|
+
props.transparent && "q-badge--transparent",
|
|
46
|
+
props.outline && "q-badge--outline",
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<template>
|
|
52
|
+
<span class="q-badge" :class="badgeClasses" :style="badgeStyle">
|
|
53
|
+
<slot>{{ label }}</slot>
|
|
54
|
+
</span>
|
|
55
|
+
</template>
|