@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,56 @@
1
+ <script setup lang="ts">
2
+ // QPage — zone de contenu. Prop `virtual` : active le virtual scroll
3
+ // (rendu fenêtré via QVirtualScroll) quand on a beaucoup d'items.
4
+ import QVirtualScroll from "./QVirtualScroll.vue"
5
+
6
+ interface Props {
7
+ /** Active le virtual scroll (rendu fenêtré des items) */
8
+ virtual?: boolean
9
+ /** Données à afficher (requis si virtual) */
10
+ items?: any[]
11
+ /** Clé d'item : string (propriété) ou fonction */
12
+ itemKey?: string | ((item: any) => unknown)
13
+ /** Nombre d'items rendus autour de la zone visible */
14
+ virtualScrollSliceSize?: number
15
+ /** Ratio d'items rendus avant la zone visible */
16
+ virtualScrollSliceRatioBefore?: number
17
+ /** Ratio d'items rendus après la zone visible */
18
+ virtualScrollSliceRatioAfter?: number
19
+ /** Taille moyenne d'un item en px (estimation initiale) */
20
+ virtualScrollItemSize?: number
21
+ /** Espace réservé en haut (contenu sticky) */
22
+ virtualScrollStickySizeStart?: number
23
+ /** Espace réservé en bas (contenu sticky) */
24
+ virtualScrollStickySizeEnd?: number
25
+ }
26
+
27
+ defineProps<Props>()
28
+ </script>
29
+
30
+ <template>
31
+ <q-virtual-scroll
32
+ v-if="virtual"
33
+ class="q-page"
34
+ :items="items"
35
+ :item-key="itemKey"
36
+ :virtual-scroll-slice-size="virtualScrollSliceSize"
37
+ :virtual-scroll-slice-ratio-before="virtualScrollSliceRatioBefore"
38
+ :virtual-scroll-slice-ratio-after="virtualScrollSliceRatioAfter"
39
+ :virtual-scroll-item-size="virtualScrollItemSize"
40
+ :virtual-scroll-sticky-size-start="virtualScrollStickySizeStart"
41
+ :virtual-scroll-sticky-size-end="virtualScrollStickySizeEnd"
42
+ >
43
+ <template #default="scope">
44
+ <slot v-bind="scope" />
45
+ </template>
46
+ <template #before>
47
+ <slot name="before" />
48
+ </template>
49
+ <template #after>
50
+ <slot name="after" />
51
+ </template>
52
+ </q-virtual-scroll>
53
+ <div v-else class="q-page">
54
+ <slot />
55
+ </div>
56
+ </template>
@@ -0,0 +1,195 @@
1
+ <script setup lang="ts">
2
+ // QPagination — pagination : <q-pagination v-model="page" :max="10" boundary-links direction-links active-color="secondary" />
3
+ // Fenêtre de pages avec ellipses, premier/dernier et précédent/suivant optionnels.
4
+ import { computed } from "vue"
5
+ import { Icon } from "@iconify/vue"
6
+ import { icons } from "../lib/icons"
7
+ import { cn } from "../lib/utils"
8
+ import { colorValue, foregroundFor } from "../lib/colors"
9
+
10
+ const SIZE_MAP: Record<string, string> = { xs: "24px", sm: "28px", md: "32px", lg: "40px", xl: "48px" }
11
+
12
+ interface Props {
13
+ /** Page courante (v-model) */
14
+ modelValue?: number
15
+ /** Première page (défaut 1) */
16
+ min?: number
17
+ /** Dernière page */
18
+ max: number
19
+ /** Nombre max de boutons de pages (0 = illimité) */
20
+ maxPages?: number
21
+ /** Boutons première/dernière */
22
+ boundaryLinks?: boolean
23
+ /** Boutons précédent/suivant */
24
+ directionLinks?: boolean
25
+ /** Icônes Iconify des boutons (défauts lucide) */
26
+ iconFirst?: string
27
+ iconPrev?: string
28
+ iconNext?: string
29
+ iconLast?: string
30
+ /** Couleur de la page active (token ou hex) */
31
+ activeColor?: string
32
+ /** Couleur des autres pages (token ou hex) */
33
+ color?: string
34
+ /** Taille : xs | sm | md | lg | xl ou valeur CSS */
35
+ size?: string
36
+ /** Affiche … pour les trous */
37
+ ellipses?: boolean
38
+ /** Variantes de boutons */
39
+ flat?: boolean
40
+ outline?: boolean
41
+ unelevated?: boolean
42
+ rounded?: boolean
43
+ square?: boolean
44
+ dense?: boolean
45
+ disable?: boolean
46
+ }
47
+
48
+ const props = withDefaults(defineProps<Props>(), {
49
+ modelValue: 1,
50
+ min: 1,
51
+ maxPages: 0,
52
+ boundaryLinks: false,
53
+ directionLinks: false,
54
+ size: "md",
55
+ ellipses: true,
56
+ activeColor: "primary",
57
+ color: "",
58
+ flat: false,
59
+ outline: false,
60
+ unelevated: false,
61
+ rounded: false,
62
+ square: false,
63
+ dense: false,
64
+ disable: false,
65
+ })
66
+
67
+ const emit = defineEmits<{ "update:modelValue": [value: number] }>()
68
+
69
+ const total = computed(() => Math.max(props.min, props.max))
70
+ const clampedPage = computed(() => Math.min(Math.max(props.modelValue, props.min), total.value))
71
+
72
+ const setPage = (p: number) => {
73
+ if (props.disable) return
74
+ emit("update:modelValue", Math.min(Math.max(p, props.min), total.value))
75
+ }
76
+
77
+ // — Fenêtre de pages avec ellipses —
78
+ const pages = computed<(number | "ellipsis")[]>(() => {
79
+ const totalPages = total.value
80
+ if (totalPages <= 1) return [1]
81
+ const maxPages = props.maxPages
82
+ if (maxPages <= 0 || totalPages <= maxPages + 2) {
83
+ return Array.from({ length: totalPages }, (_, i) => i + 1)
84
+ }
85
+ const cur = clampedPage.value
86
+ let start = Math.max(1, cur - Math.floor((maxPages - 1) / 2))
87
+ const end = Math.min(totalPages, start + maxPages - 1)
88
+ start = Math.max(1, end - maxPages + 1)
89
+
90
+ const list: (number | "ellipsis")[] = []
91
+ if (start > 2) list.push(1, "ellipsis")
92
+ else if (start === 2) list.push(1)
93
+ for (let i = start; i <= end; i++) list.push(i)
94
+ if (end < totalPages - 1) list.push("ellipsis", totalPages)
95
+ else if (end === totalPages - 1) list.push(totalPages)
96
+ return list
97
+ })
98
+
99
+ const btnClasses = computed(() =>
100
+ cn(
101
+ "q-pagination__btn",
102
+ props.flat && "q-pagination__btn--flat",
103
+ props.outline && "q-pagination__btn--outline",
104
+ props.unelevated && "q-pagination__btn--unelevated",
105
+ props.rounded && "q-pagination__btn--rounded",
106
+ props.square && "q-pagination__btn--square",
107
+ props.dense && "q-pagination__btn--dense",
108
+ ),
109
+ )
110
+
111
+ const btnSize = computed<Record<string, string>>(() => {
112
+ const s = SIZE_MAP[props.size] ?? props.size
113
+ return { minWidth: s, height: s }
114
+ })
115
+
116
+ const activeStyle = computed<Record<string, string>>(() => ({
117
+ backgroundColor: colorValue(props.activeColor),
118
+ color: foregroundFor(props.activeColor),
119
+ borderColor: "transparent",
120
+ }))
121
+
122
+ const colorStyle = computed<Record<string, string> | undefined>(() =>
123
+ props.color ? { color: colorValue(props.color) } : undefined,
124
+ )
125
+ </script>
126
+
127
+ <template>
128
+ <div class="q-pagination" :class="{ 'q-pagination--disabled': disable }" role="navigation" aria-label="Pagination">
129
+ <button
130
+ v-if="boundaryLinks"
131
+ type="button"
132
+ class="q-pagination__btn"
133
+ :class="btnClasses"
134
+ :style="[btnSize, colorStyle]"
135
+ :disabled="disable || clampedPage <= min"
136
+ aria-label="Première page"
137
+ @click="setPage(min)"
138
+ >
139
+ <Icon :icon="iconFirst || icons.chevronsLeft" aria-hidden="true" />
140
+ </button>
141
+ <button
142
+ v-if="directionLinks"
143
+ type="button"
144
+ class="q-pagination__btn"
145
+ :class="btnClasses"
146
+ :style="[btnSize, colorStyle]"
147
+ :disabled="disable || clampedPage <= min"
148
+ aria-label="Page précédente"
149
+ @click="setPage(clampedPage - 1)"
150
+ >
151
+ <Icon :icon="iconPrev || icons.chevronLeft" aria-hidden="true" />
152
+ </button>
153
+
154
+ <button
155
+ v-for="(p, i) in pages"
156
+ :key="i"
157
+ type="button"
158
+ class="q-pagination__btn"
159
+ :class="[btnClasses, p === clampedPage && 'q-pagination__btn--active']"
160
+ :style="[btnSize, p === clampedPage ? activeStyle : colorStyle]"
161
+ :disabled="disable || p === 'ellipsis'"
162
+ :aria-label="p === 'ellipsis' ? 'Pages intermédiaires' : `Page ${p}`"
163
+ :aria-current="p === clampedPage ? 'page' : undefined"
164
+ @click="p !== 'ellipsis' && setPage(p)"
165
+ >
166
+ <span v-if="p === 'ellipsis'" class="q-pagination__ellipsis" aria-hidden="true">…</span>
167
+ <span v-else>{{ p }}</span>
168
+ </button>
169
+
170
+ <button
171
+ v-if="directionLinks"
172
+ type="button"
173
+ class="q-pagination__btn"
174
+ :class="btnClasses"
175
+ :style="[btnSize, colorStyle]"
176
+ :disabled="disable || clampedPage >= total"
177
+ aria-label="Page suivante"
178
+ @click="setPage(clampedPage + 1)"
179
+ >
180
+ <Icon :icon="iconNext || icons.chevronRight" aria-hidden="true" />
181
+ </button>
182
+ <button
183
+ v-if="boundaryLinks"
184
+ type="button"
185
+ class="q-pagination__btn"
186
+ :class="btnClasses"
187
+ :style="[btnSize, colorStyle]"
188
+ :disabled="disable || clampedPage >= total"
189
+ aria-label="Dernière page"
190
+ @click="setPage(total)"
191
+ >
192
+ <Icon :icon="iconLast || icons.chevronsRight" aria-hidden="true" />
193
+ </button>
194
+ </div>
195
+ </template>
@@ -0,0 +1,124 @@
1
+ <script setup lang="ts">
2
+ // QParallax — effet parallaxe type Quasar : <q-parallax :src="img" :height="300" :speed="0.6">
3
+ // L'image de fond se déplace plus lentement que le scroll de la fenêtre (ou du
4
+ // conteneur scrollable parent), en restant toujours couvrante. Content via slot.
5
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"
6
+ import { cn } from "../lib/utils"
7
+
8
+ interface Props {
9
+ /** Image de fond (URL) */
10
+ src?: string
11
+ /** Hauteur du bloc (px ou chaîne CSS) — défaut 200 */
12
+ height?: number | string
13
+ /** Intensité du parallaxe 0.1–1 (défaut 1) */
14
+ speed?: number
15
+ /** Désactive l'effet (image statique, cover) */
16
+ disable?: boolean
17
+ contentClass?: string
18
+ }
19
+
20
+ const props = withDefaults(defineProps<Props>(), {
21
+ height: 200,
22
+ speed: 1,
23
+ disable: false,
24
+ contentClass: "",
25
+ })
26
+
27
+ const containerEl = ref<HTMLElement | null>(null)
28
+ const mediaEl = ref<HTMLElement | null>(null)
29
+ const mediaStyle = ref<Record<string, string>>({})
30
+
31
+ let scrollParent: HTMLElement | Window | null = null
32
+ let raf = 0
33
+
34
+ const reducedMotion =
35
+ typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
36
+
37
+ const update = () => {
38
+ const el = containerEl.value
39
+ const media = mediaEl.value
40
+ if (!el || !media) return
41
+ const h = el.offsetHeight
42
+ const speed = Math.min(1, Math.max(0.1, props.speed))
43
+ const mediaH = h * (1 + 2 * 0.15 * speed) // 1.3× à speed 1
44
+ const travel = (mediaH - h) / 2
45
+
46
+ // Viewport visible : la fenêtre, ou le conteneur scrollable détecté
47
+ let vpTop = 0
48
+ let vpH = window.innerHeight
49
+ if (scrollParent && scrollParent !== window) {
50
+ const r = (scrollParent as HTMLElement).getBoundingClientRect()
51
+ vpTop = r.top
52
+ vpH = r.height
53
+ }
54
+ const vpCenter = vpTop + vpH / 2
55
+ const elCenter = el.getBoundingClientRect().top + h / 2
56
+ const maxDist = vpH / 2 + h / 2
57
+ const percent = Math.max(-1, Math.min(1, (elCenter - vpCenter) / maxDist))
58
+ const parallax = -(travel * percent)
59
+
60
+ mediaStyle.value = {
61
+ top: -travel + "px",
62
+ height: mediaH + "px",
63
+ transform:
64
+ props.disable || reducedMotion ? "none" : `translate3d(0, ${parallax}px, 0)`,
65
+ }
66
+ }
67
+
68
+ const schedule = () => {
69
+ if (props.disable || reducedMotion) return
70
+ if (raf) cancelAnimationFrame(raf)
71
+ raf = requestAnimationFrame(update)
72
+ }
73
+
74
+ const findScrollParent = (el: HTMLElement): HTMLElement | Window => {
75
+ let node = el.parentElement
76
+ while (node) {
77
+ const s = getComputedStyle(node)
78
+ if (/(auto|scroll|overlay)/.test(s.overflowY) && node.scrollHeight > node.clientHeight)
79
+ return node
80
+ node = node.parentElement
81
+ }
82
+ return window
83
+ }
84
+
85
+ onMounted(() => {
86
+ if (typeof window === "undefined") return
87
+ scrollParent = findScrollParent(containerEl.value!)
88
+ const target = scrollParent === window ? window : scrollParent
89
+ target.addEventListener("scroll", schedule, { passive: true })
90
+ window.addEventListener("resize", schedule)
91
+ update()
92
+ })
93
+
94
+ onBeforeUnmount(() => {
95
+ if (raf) cancelAnimationFrame(raf)
96
+ if (scrollParent) {
97
+ const target = scrollParent === window ? window : scrollParent
98
+ target.removeEventListener("scroll", schedule)
99
+ }
100
+ window.removeEventListener("resize", schedule)
101
+ })
102
+
103
+ watch(() => props.disable, update)
104
+
105
+ const containerStyle = computed<Record<string, string>>(() => ({
106
+ height: typeof props.height === "number" ? props.height + "px" : props.height,
107
+ }))
108
+ </script>
109
+
110
+ <template>
111
+ <div
112
+ ref="containerEl"
113
+ class="q-parallax"
114
+ :class="cn(props.disable && 'q-parallax--disabled')"
115
+ :style="containerStyle"
116
+ >
117
+ <div ref="mediaEl" class="q-parallax__media" :style="mediaStyle">
118
+ <img v-if="src" :src="src" alt="" loading="lazy" draggable="false" />
119
+ </div>
120
+ <div class="q-parallax__content" :class="contentClass">
121
+ <slot />
122
+ </div>
123
+ </div>
124
+ </template>
@@ -0,0 +1,138 @@
1
+ <script setup lang="ts">
2
+ // QPullToRefresh — tirer pour rafraîchir : <q-pull-to-refresh @refresh="(done) => { ...; done() }">
3
+ // Conteneur scrollable ; geste de traction quand scrollTop = 0 → indicateur + refresh.
4
+ import { computed, ref } from "vue"
5
+ import { Icon } from "@iconify/vue"
6
+ import { icons } from "../lib/icons"
7
+ import { colorValue } from "../lib/colors"
8
+
9
+ interface Props {
10
+ /** État de rafraîchissement (v-model) */
11
+ modelValue?: boolean
12
+ /** Couleur de l'indicateur (token ou hex) */
13
+ color?: string
14
+ /** Distance (px) de traction avant déclenchement */
15
+ pullBack?: number
16
+ /** Icône Iconify de l'indicateur (défaut : lucide:refresh-cw) */
17
+ icon?: string
18
+ /** Taille de l'icône / du spinner de l'indicateur (CSS, défaut 28px) */
19
+ size?: string
20
+ disable?: boolean
21
+ }
22
+
23
+ const props = withDefaults(defineProps<Props>(), {
24
+ modelValue: false,
25
+ color: "primary",
26
+ pullBack: 40,
27
+ size: "28px",
28
+ disable: false,
29
+ })
30
+
31
+ const emit = defineEmits<{
32
+ "update:modelValue": [value: boolean]
33
+ /** Appelé quand le seuil est atteint ; appeler done() pour terminer */
34
+ refresh: [done: () => void]
35
+ }>()
36
+
37
+ const internalRefreshing = ref(false)
38
+ const refreshing = computed({
39
+ get: () => props.modelValue ?? internalRefreshing.value,
40
+ set: (v) => {
41
+ internalRefreshing.value = v
42
+ emit("update:modelValue", v)
43
+ },
44
+ })
45
+
46
+ const done = () => {
47
+ refreshing.value = false
48
+ pullDistance.value = 0
49
+ }
50
+
51
+ // — Geste de traction (Pointer Events : tactile + souris) —
52
+ const contentRef = ref<HTMLElement | null>(null)
53
+ const pulling = ref(false)
54
+ const startY = ref(0)
55
+ const pullDistance = ref(0)
56
+ const RESISTANCE = 0.45
57
+
58
+ const progress = computed(() => Math.min(1, pullDistance.value / props.pullBack))
59
+
60
+ const onPointerDown = (e: PointerEvent) => {
61
+ if (props.disable || refreshing.value) return
62
+ if (contentRef.value && contentRef.value.scrollTop > 0) return
63
+ startY.value = e.clientY
64
+ pulling.value = true
65
+ ;(e.target as HTMLElement).setPointerCapture(e.pointerId)
66
+ }
67
+
68
+ const onPointerMove = (e: PointerEvent) => {
69
+ if (!pulling.value) return
70
+ const dy = e.clientY - startY.value
71
+ if (dy <= 0 || (contentRef.value && contentRef.value.scrollTop > 0)) {
72
+ pullDistance.value = 0
73
+ return
74
+ }
75
+ pullDistance.value = Math.min(props.pullBack * 3, dy * RESISTANCE)
76
+ }
77
+
78
+ const onPointerUp = () => {
79
+ if (!pulling.value) return
80
+ pulling.value = false
81
+ if (pullDistance.value >= props.pullBack) {
82
+ refreshing.value = true
83
+ emit("refresh", done)
84
+ }
85
+ pullDistance.value = 0
86
+ }
87
+
88
+ const indicatorY = computed(() => (refreshing.value ? 60 : pullDistance.value))
89
+
90
+ const indicatorStyle = computed<Record<string, string>>(() => ({
91
+ transform: `translateY(${indicatorY.value}px)`,
92
+ }))
93
+
94
+ const iconStyle = computed<Record<string, string>>(() => ({
95
+ color: colorValue(props.color),
96
+ width: props.size,
97
+ height: props.size,
98
+ transform: `rotate(${progress.value * 180}deg)`,
99
+ }))
100
+
101
+ const rootClasses = computed(() => [
102
+ "q-pull-to-refresh",
103
+ pulling.value && "q-pull-to-refresh--pulling",
104
+ refreshing.value && "q-pull-to-refresh--refreshing",
105
+ ])
106
+ </script>
107
+
108
+ <template>
109
+ <div
110
+ ref="contentRef"
111
+ class="q-pull-to-refresh"
112
+ :class="rootClasses"
113
+ @pointerdown="onPointerDown"
114
+ @pointermove="onPointerMove"
115
+ @pointerup="onPointerUp"
116
+ @pointercancel="onPointerUp"
117
+ >
118
+ <div class="q-pull-to-refresh__indicator" :style="indicatorStyle">
119
+ <template v-if="refreshing">
120
+ <slot name="refreshing">
121
+ <span
122
+ class="q-spinner-ring"
123
+ :style="{ color: colorValue(color), width: size, height: size }"
124
+ aria-hidden="true"
125
+ />
126
+ </slot>
127
+ </template>
128
+ <template v-else>
129
+ <slot name="pulling" :position="pullDistance">
130
+ <Icon :icon="icon || icons.refreshCw" class="q-pull-to-refresh__icon" :style="iconStyle" aria-hidden="true" />
131
+ </slot>
132
+ </template>
133
+ </div>
134
+ <div class="q-pull-to-refresh__inner">
135
+ <slot />
136
+ </div>
137
+ </div>
138
+ </template>
@@ -0,0 +1,99 @@
1
+ <script setup lang="ts">
2
+ // QRadio — API Quasar : <q-radio v-model="val" val="a" label="Option A" color="secondary" dense left-label />
3
+ // Sélectionné quand modelValue === val (ou trueValue si pas de val).
4
+ import { computed } from "vue"
5
+ import { cn } from "../lib/utils"
6
+ import { colorValue } from "../lib/colors"
7
+
8
+ interface Props {
9
+ modelValue?: unknown
10
+ /** Valeur que ce radio représente */
11
+ val?: unknown
12
+ trueValue?: unknown
13
+ falseValue?: unknown
14
+ /** Couleur (token ou hex) */
15
+ color?: string
16
+ /** Applique la couleur aussi à l'état non sélectionné */
17
+ keepColor?: boolean
18
+ dense?: boolean
19
+ dark?: boolean
20
+ disable?: boolean
21
+ readonly?: boolean
22
+ label?: string
23
+ /** Label à gauche du contrôle */
24
+ leftLabel?: boolean
25
+ }
26
+
27
+ const props = withDefaults(defineProps<Props>(), {
28
+ color: "primary",
29
+ keepColor: false,
30
+ dense: false,
31
+ dark: false,
32
+ disable: false,
33
+ readonly: false,
34
+ leftLabel: false,
35
+ })
36
+
37
+ // Defaults manuels (withDefaults ne gère pas les defaults sur props typées unknown)
38
+ const trueValue = computed(() => props.trueValue ?? true)
39
+ const falseValue = computed(() => props.falseValue ?? false)
40
+
41
+ const emit = defineEmits<{
42
+ "update:modelValue": [value: unknown]
43
+ focus: [event: FocusEvent]
44
+ blur: [event: FocusEvent]
45
+ }>()
46
+
47
+ const selectedValue = computed(() => props.val ?? trueValue.value)
48
+ const isSelected = computed(() => props.modelValue === selectedValue.value)
49
+ const isDisabled = computed(() => props.disable || props.readonly)
50
+
51
+ const select = () => {
52
+ if (isDisabled.value) return
53
+ emit("update:modelValue", selectedValue.value)
54
+ }
55
+
56
+ const circleStyle = computed<Record<string, string>>(() => {
57
+ const style: Record<string, string> = {}
58
+ const color = colorValue(props.color)
59
+ if (isSelected.value || props.keepColor) style.borderColor = color
60
+ return style
61
+ })
62
+
63
+ const dotStyle = computed<Record<string, string>>((): Record<string, string> =>
64
+ isSelected.value ? { backgroundColor: colorValue(props.color) } : {},
65
+ )
66
+
67
+ const rootClasses = computed(() =>
68
+ cn(
69
+ "q-radio",
70
+ props.dense && "q-radio--dense",
71
+ props.dark && "q-radio--dark",
72
+ props.disable && "q-radio--disabled",
73
+ ),
74
+ )
75
+ </script>
76
+
77
+ <template>
78
+ <button
79
+ type="button"
80
+ role="radio"
81
+ :aria-checked="isSelected ? 'true' : 'false'"
82
+ :disabled="isDisabled"
83
+ class="q-radio"
84
+ :class="rootClasses"
85
+ @click="select"
86
+ @focus="(e) => emit('focus', e)"
87
+ @blur="(e) => emit('blur', e)"
88
+ >
89
+ <span v-if="leftLabel && (label !== undefined || $slots.default)" class="q-radio__label">
90
+ <slot>{{ label }}</slot>
91
+ </span>
92
+ <span class="q-radio__circle" :style="circleStyle" aria-hidden="true">
93
+ <span v-if="isSelected" class="q-radio__dot" :style="dotStyle" />
94
+ </span>
95
+ <span v-if="!leftLabel && (label !== undefined || $slots.default)" class="q-radio__label">
96
+ <slot>{{ label }}</slot>
97
+ </span>
98
+ </button>
99
+ </template>