@dnax/ui 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/README.md +15 -0
  2. package/components/QAccordion.vue +68 -0
  3. package/components/QAccordionContent.vue +62 -0
  4. package/components/QAccordionItem.vue +24 -0
  5. package/components/QAccordionTrigger.vue +51 -0
  6. package/components/QActionSheet.vue +159 -0
  7. package/components/QApp.vue +8 -0
  8. package/components/QAutocomplete.vue +316 -0
  9. package/components/QAvatar.vue +92 -0
  10. package/components/QBackHeader.vue +69 -0
  11. package/components/QBackTop.vue +125 -0
  12. package/components/QBadge.vue +55 -0
  13. package/components/QBottomSheet.vue +177 -0
  14. package/components/QBottomSheetFooter.vue +9 -0
  15. package/components/QBottomSheetHeader.vue +35 -0
  16. package/components/QBottomSheetProvider.vue +62 -0
  17. package/components/QBottomSheetTrigger.vue +18 -0
  18. package/components/QBtn.vue +144 -0
  19. package/components/QBtnGroup.vue +31 -0
  20. package/components/QBubble.vue +36 -0
  21. package/components/QBubbleContent.vue +28 -0
  22. package/components/QBubbleGroup.vue +25 -0
  23. package/components/QBubbleReactions.vue +40 -0
  24. package/components/QCard.vue +63 -0
  25. package/components/QCardActions.vue +27 -0
  26. package/components/QCardContent.vue +29 -0
  27. package/components/QCarousel.vue +115 -0
  28. package/components/QCarouselContent.vue +23 -0
  29. package/components/QCarouselItem.vue +20 -0
  30. package/components/QCarouselNav.vue +51 -0
  31. package/components/QCheckbox.vue +146 -0
  32. package/components/QChip.vue +91 -0
  33. package/components/QCircularProgress.vue +122 -0
  34. package/components/QCol.vue +52 -0
  35. package/components/QCollapse.vue +141 -0
  36. package/components/QConfigProvider.vue +164 -0
  37. package/components/QContainer.vue +201 -0
  38. package/components/QCountDown.vue +164 -0
  39. package/components/QCountryPicker.vue +593 -0
  40. package/components/QDataGrid.vue +491 -0
  41. package/components/QDatePicker.vue +250 -0
  42. package/components/QDialog.vue +163 -0
  43. package/components/QDialogFooter.vue +9 -0
  44. package/components/QDialogHeader.vue +41 -0
  45. package/components/QDialogProvider.vue +65 -0
  46. package/components/QDialogTrigger.vue +18 -0
  47. package/components/QFab.vue +80 -0
  48. package/components/QFabAction.vue +48 -0
  49. package/components/QFilePicker.vue +294 -0
  50. package/components/QFooter.vue +72 -0
  51. package/components/QGallery.vue +280 -0
  52. package/components/QGrid.vue +54 -0
  53. package/components/QHeader.vue +38 -0
  54. package/components/QIcon.vue +53 -0
  55. package/components/QImagePicker.vue +248 -0
  56. package/components/QImagePreview.vue +225 -0
  57. package/components/QImagePreviewProvider.vue +35 -0
  58. package/components/QImg.vue +142 -0
  59. package/components/QInfiniteScroll.vue +109 -0
  60. package/components/QInnerLoading.vue +67 -0
  61. package/components/QInput.vue +289 -0
  62. package/components/QInputOtp.vue +238 -0
  63. package/components/QInputPassword.vue +67 -0
  64. package/components/QInputTag.vue +281 -0
  65. package/components/QIntersection.vue +119 -0
  66. package/components/QItem.vue +60 -0
  67. package/components/QItemSection.vue +47 -0
  68. package/components/QLinearProgress.vue +94 -0
  69. package/components/QList.vue +39 -0
  70. package/components/QLoading.vue +77 -0
  71. package/components/QLoadingProvider.vue +41 -0
  72. package/components/QMarquee.vue +56 -0
  73. package/components/QMessageScroller.vue +10 -0
  74. package/components/QMessageScrollerButton.vue +48 -0
  75. package/components/QMessageScrollerContent.vue +31 -0
  76. package/components/QMessageScrollerItem.vue +49 -0
  77. package/components/QMessageScrollerProvider.vue +198 -0
  78. package/components/QMessageScrollerViewport.vue +109 -0
  79. package/components/QNavMenu.vue +75 -0
  80. package/components/QNavMenuContent.vue +10 -0
  81. package/components/QNavMenuItem.vue +60 -0
  82. package/components/QNavMenuTrigger.vue +96 -0
  83. package/components/QNotifyProvider.vue +33 -0
  84. package/components/QNotifyToast.vue +60 -0
  85. package/components/QPage.vue +56 -0
  86. package/components/QPagination.vue +195 -0
  87. package/components/QParallax.vue +124 -0
  88. package/components/QPullToRefresh.vue +138 -0
  89. package/components/QRadio.vue +99 -0
  90. package/components/QRating.vue +105 -0
  91. package/components/QReorder.vue +191 -0
  92. package/components/QRollingText.vue +225 -0
  93. package/components/QRow.vue +23 -0
  94. package/components/QSafeArea.vue +39 -0
  95. package/components/QScrollArea.vue +118 -0
  96. package/components/QSelect.vue +698 -0
  97. package/components/QSeparator.vue +39 -0
  98. package/components/QSidebar.vue +153 -0
  99. package/components/QSidebarContent.vue +14 -0
  100. package/components/QSidebarFooter.vue +14 -0
  101. package/components/QSidebarHeader.vue +14 -0
  102. package/components/QSidebarMenu.vue +9 -0
  103. package/components/QSidebarMenuButton.vue +59 -0
  104. package/components/QSidebarMenuItem.vue +9 -0
  105. package/components/QSidebarTrigger.vue +32 -0
  106. package/components/QSkeleton.vue +95 -0
  107. package/components/QSlider.vue +205 -0
  108. package/components/QSpace.vue +10 -0
  109. package/components/QSpinner.vue +172 -0
  110. package/components/QSplitter.vue +145 -0
  111. package/components/QSticky.vue +181 -0
  112. package/components/QSwipeCell.vue +217 -0
  113. package/components/QSwiper.vue +102 -0
  114. package/components/QSwiperSlide.vue +19 -0
  115. package/components/QSyntax.vue +171 -0
  116. package/components/QTab.vue +175 -0
  117. package/components/QTabPanel.vue +50 -0
  118. package/components/QTabPanels.vue +106 -0
  119. package/components/QTable.vue +575 -0
  120. package/components/QTabs.vue +188 -0
  121. package/components/QText.vue +316 -0
  122. package/components/QTextCaption.vue +122 -0
  123. package/components/QToolbar.vue +28 -0
  124. package/components/QTooltip.vue +313 -0
  125. package/components/QUploader.vue +473 -0
  126. package/components/QVideo.vue +289 -0
  127. package/components/QVirtualScroll.vue +190 -0
  128. package/components/internal/QDateCalendar.vue +153 -0
  129. package/index.ts +151 -0
  130. package/lib/breakpoints.ts +44 -0
  131. package/lib/colors.ts +24 -0
  132. package/lib/config.ts +34 -0
  133. package/lib/countries.ts +261 -0
  134. package/lib/formula.ts +630 -0
  135. package/lib/icons.ts +38 -0
  136. package/lib/loading.ts +75 -0
  137. package/lib/overlayBack.ts +124 -0
  138. package/lib/platform.ts +146 -0
  139. package/lib/primitive.ts +25 -0
  140. package/lib/q.ts +287 -0
  141. package/lib/screen.ts +105 -0
  142. package/lib/search.ts +35 -0
  143. package/lib/themeVars.ts +22 -0
  144. package/lib/useComponentProps.ts +54 -0
  145. package/lib/utils.ts +7 -0
  146. package/module.ts +89 -0
  147. package/package.json +41 -0
  148. package/scripts/generate-exports.ts +56 -0
  149. package/styles/main.css +7627 -0
  150. package/tsconfig.json +33 -0
@@ -0,0 +1,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>