@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,172 @@
1
+ <script setup lang="ts">
2
+ // QSpinner — indicateur de chargement : <q-spinner size="30px" color="primary" :thickness="4" type="tail" />
3
+ // Variantes SVG (type) : tail (défaut), oval, bars, dots, grid, rings, puff, audio.
4
+ import { computed } from "vue"
5
+ import { colorValue } from "../lib/colors"
6
+
7
+ type SpinnerType =
8
+ | "tail"
9
+ | "oval"
10
+ | "bars"
11
+ | "dots"
12
+ | "grid"
13
+ | "rings"
14
+ | "puff"
15
+ | "audio"
16
+ | "ios"
17
+ | "radio"
18
+ | "gear"
19
+ | "gears"
20
+ | "cube"
21
+
22
+ interface Props {
23
+ /** Variante : tail (défaut) | oval | bars | dots | grid | rings | puff | audio | ios | radio | gear | gears | cube */
24
+ type?: SpinnerType
25
+ /** Taille (px ou chaîne CSS) — défaut 32px */
26
+ size?: string | number
27
+ /** Couleur (token ou hex) — défaut primary */
28
+ color?: string
29
+ /** Épaisseur du trait (SVG stroke-width) — défaut 4 */
30
+ thickness?: number
31
+ }
32
+
33
+ const props = withDefaults(defineProps<Props>(), {
34
+ type: "tail",
35
+ size: 32,
36
+ color: "primary",
37
+ thickness: 4,
38
+ })
39
+
40
+ const rootStyle = computed<Record<string, string>>(() => ({
41
+ width: typeof props.size === "number" ? props.size + "px" : props.size,
42
+ height: typeof props.size === "number" ? props.size + "px" : props.size,
43
+ color: colorValue(props.color),
44
+ }))
45
+ </script>
46
+
47
+ <template>
48
+ <span class="q-spinner" :class="`q-spinner--${type}`" :style="rootStyle" role="status" aria-label="Loading">
49
+ <!-- Tail : piste + arc qui tourne -->
50
+ <svg v-if="type === 'tail'" viewBox="0 0 44 44" class="q-spinner__svg">
51
+ <circle cx="22" cy="22" r="17" fill="none" stroke="currentColor" :stroke-width="thickness" opacity="0.25" />
52
+ <path
53
+ d="M22 5 a17 17 0 0 1 17 17"
54
+ fill="none"
55
+ stroke="currentColor"
56
+ :stroke-width="thickness"
57
+ stroke-linecap="round"
58
+ class="q-spinner__arc"
59
+ />
60
+ </svg>
61
+
62
+ <!-- Oval : cercle hachuré qui tourne -->
63
+ <svg v-else-if="type === 'oval'" viewBox="0 0 44 44" class="q-spinner__svg">
64
+ <circle
65
+ cx="22"
66
+ cy="22"
67
+ r="16"
68
+ fill="none"
69
+ stroke="currentColor"
70
+ :stroke-width="thickness"
71
+ stroke-linecap="round"
72
+ stroke-dasharray="55 120"
73
+ class="q-spinner__oval"
74
+ />
75
+ </svg>
76
+
77
+ <!-- Bars : 5 barres qui ondulent -->
78
+ <svg v-else-if="type === 'bars'" viewBox="0 0 44 44" class="q-spinner__svg q-spinner__svg--wide">
79
+ <rect v-for="(y, i) in [18, 12, 6, 12, 18]" :key="i" :x="2 + i * 9" :y="y" width="5" :height="44 - y * 2" rx="2.5" fill="currentColor" class="q-spinner__bar" :class="`q-spinner__bar--${i + 1}`" />
80
+ </svg>
81
+
82
+ <!-- Dots : 3 points qui rebondissent -->
83
+ <svg v-else-if="type === 'dots'" viewBox="0 0 80 24" class="q-spinner__svg q-spinner__svg--wide">
84
+ <circle v-for="i in 3" :key="i" :cx="10 + (i - 1) * 30" cy="12" r="6" fill="currentColor" class="q-spinner__dot" :class="`q-spinner__dot--${i}`" />
85
+ </svg>
86
+
87
+ <!-- Grid : 3×3 points pulsants -->
88
+ <svg v-else-if="type === 'grid'" viewBox="0 0 44 44" class="q-spinner__svg">
89
+ <circle
90
+ v-for="i in 9"
91
+ :key="i"
92
+ :cx="8 + ((i - 1) % 3) * 14"
93
+ :cy="8 + Math.floor((i - 1) / 3) * 14"
94
+ r="4.5"
95
+ fill="currentColor"
96
+ class="q-spinner__cell"
97
+ :class="`q-spinner__cell--${i}`"
98
+ />
99
+ </svg>
100
+
101
+ <!-- Rings : deux anneaux contrarotatifs -->
102
+ <svg v-else-if="type === 'rings'" viewBox="0 0 44 44" class="q-spinner__svg">
103
+ <circle cx="22" cy="22" r="17" fill="none" stroke="currentColor" :stroke-width="thickness" opacity="0.35" class="q-spinner__ring" />
104
+ <circle cx="22" cy="22" r="10" fill="none" stroke="currentColor" :stroke-width="thickness" stroke-linecap="round" class="q-spinner__ring q-spinner__ring--2" />
105
+ </svg>
106
+
107
+ <!-- Puff : point qui pulse -->
108
+ <svg v-else-if="type === 'puff'" viewBox="0 0 44 44" class="q-spinner__svg">
109
+ <circle cx="22" cy="22" r="14" fill="currentColor" class="q-spinner__puff" />
110
+ </svg>
111
+
112
+ <!-- Audio : égaliseur -->
113
+ <svg v-else-if="type === 'audio'" viewBox="0 0 28 44" class="q-spinner__svg q-spinner__svg--wide">
114
+ <rect v-for="(y, i) in [14, 6, 10]" :key="i" :x="2 + i * 9" :y="y" width="5" :height="44 - y * 2" rx="2.5" fill="currentColor" class="q-spinner__eq" :class="`q-spinner__eq--${i + 1}`" />
115
+ </svg>
116
+
117
+ <!-- Ios : 12 segments qui s'estompent en séquence -->
118
+ <svg v-else-if="type === 'ios'" viewBox="0 0 44 44" class="q-spinner__svg">
119
+ <g v-for="i in 12" :key="i" :transform="`rotate(${(i - 1) * 30} 22 22)`">
120
+ <line x1="22" y1="7" x2="22" y2="11" stroke="currentColor" :stroke-width="2.5" stroke-linecap="round" class="q-spinner__seg" :class="`q-spinner__seg--${i}`" />
121
+ </g>
122
+ </svg>
123
+
124
+ <!-- Radio : point + ondes qui se propagent -->
125
+ <svg v-else-if="type === 'radio'" viewBox="0 0 44 44" class="q-spinner__svg">
126
+ <circle cx="22" cy="22" r="4" fill="currentColor" class="q-spinner__radio-dot" />
127
+ <circle cx="22" cy="22" r="4" fill="none" stroke="currentColor" :stroke-width="2.5" class="q-spinner__radio-ring" />
128
+ <circle cx="22" cy="22" r="4" fill="none" stroke="currentColor" :stroke-width="2.5" class="q-spinner__radio-ring q-spinner__radio-ring--2" />
129
+ </svg>
130
+
131
+ <!-- Gear : un seul engrenage -->
132
+ <svg v-else-if="type === 'gear'" viewBox="0 0 44 44" class="q-spinner__svg">
133
+ <g transform="translate(22 22)">
134
+ <g class="q-spinner__cog">
135
+ <circle r="9.5" fill="none" stroke="currentColor" :stroke-width="9" />
136
+ <g v-for="i in 8" :key="i" :transform="`rotate(${(i - 1) * 45})`">
137
+ <rect x="-1.5" y="-15" width="3" height="8" rx="1.2" fill="currentColor" />
138
+ </g>
139
+ </g>
140
+ </g>
141
+ </svg>
142
+
143
+ <!-- Gears : deux engrenages contrarotatifs -->
144
+ <svg v-else-if="type === 'gears'" viewBox="0 0 44 44" class="q-spinner__svg">
145
+ <g transform="translate(15 22)">
146
+ <g class="q-spinner__cog">
147
+ <circle r="9.5" fill="none" stroke="currentColor" :stroke-width="9" />
148
+ <g v-for="i in 8" :key="i" :transform="`rotate(${(i - 1) * 45})`">
149
+ <rect x="-1.5" y="-15" width="3" height="8" rx="1.2" fill="currentColor" />
150
+ </g>
151
+ </g>
152
+ </g>
153
+ <g transform="translate(31 15) scale(0.55)">
154
+ <g class="q-spinner__cog q-spinner__cog--2">
155
+ <circle r="9.5" fill="none" stroke="currentColor" :stroke-width="9" />
156
+ <g v-for="i in 8" :key="i" :transform="`rotate(${(i - 1) * 45})`">
157
+ <rect x="-1.5" y="-15" width="3" height="8" rx="1.2" fill="currentColor" />
158
+ </g>
159
+ </g>
160
+ </g>
161
+ </svg>
162
+
163
+ <!-- Cube : cube isométrique qui tourne -->
164
+ <svg v-else viewBox="0 0 44 44" class="q-spinner__svg">
165
+ <g class="q-spinner__cube">
166
+ <path d="M22 4 L40 14 L22 24 L4 14 Z" fill="currentColor" opacity="0.9" />
167
+ <path d="M4 14 L22 24 L22 42 L4 32 Z" fill="currentColor" opacity="0.55" />
168
+ <path d="M40 14 L22 24 L22 42 L40 32 Z" fill="currentColor" opacity="0.3" />
169
+ </g>
170
+ </svg>
171
+ </span>
172
+ </template>
@@ -0,0 +1,145 @@
1
+ <script setup lang="ts">
2
+ // QSplitter — API Quasar : <q-splitter v-model="ratio" horizontal :limits="[20, 80]" unit="%">
3
+ // Équivalent du Resizable shadcn-vue : panneaux flex + handle draggable (pointer) + clavier.
4
+ import { computed, ref } from "vue"
5
+ import { cn } from "../lib/utils"
6
+
7
+ interface Props {
8
+ /** Ratio de division : % (0-100) ou px selon unit */
9
+ modelValue?: number
10
+ /** Division horizontale (sinon verticale) */
11
+ horizontal?: boolean
12
+ /** Limites [min, max] du panneau avant, en % (défaut [0, 50]) */
13
+ limits?: [number, number]
14
+ /** Inverse les deux panneaux */
15
+ reverse?: boolean
16
+ /** Unité de modelValue */
17
+ unit?: "%" | "px"
18
+ disable?: boolean
19
+ separatorClass?: string
20
+ separatorStyle?: string
21
+ beforeClass?: string
22
+ beforeStyle?: string
23
+ afterClass?: string
24
+ afterStyle?: string
25
+ }
26
+
27
+ const props = withDefaults(defineProps<Props>(), {
28
+ modelValue: 50,
29
+ horizontal: false,
30
+ limits: () => [0, 50],
31
+ reverse: false,
32
+ unit: "%",
33
+ disable: false,
34
+ separatorClass: "",
35
+ separatorStyle: "",
36
+ beforeClass: "",
37
+ beforeStyle: "",
38
+ afterClass: "",
39
+ afterStyle: "",
40
+ })
41
+
42
+ const emit = defineEmits<{ "update:modelValue": [value: number] }>()
43
+
44
+ const containerRef = ref<HTMLElement | null>(null)
45
+ const dragging = ref(false)
46
+
47
+ // — Drag du handle —
48
+ const onPointerDown = (e: PointerEvent) => {
49
+ if (props.disable) return
50
+ dragging.value = true
51
+ ;(e.target as HTMLElement).setPointerCapture(e.pointerId)
52
+ }
53
+
54
+ const onPointerMove = (e: PointerEvent) => {
55
+ if (!dragging.value || props.disable) return
56
+ const rect = containerRef.value?.getBoundingClientRect()
57
+ if (!rect) return
58
+ const total = props.horizontal ? rect.height : rect.width
59
+ if (total <= 0) return
60
+ const pos = props.horizontal ? e.clientY - rect.top : e.clientX - rect.left
61
+ const pct = Math.max(props.limits[0], Math.min(props.limits[1], (pos / total) * 100))
62
+ emit("update:modelValue", props.unit === "%" ? pct : (pct / 100) * total)
63
+ }
64
+
65
+ const onPointerUp = () => {
66
+ dragging.value = false
67
+ }
68
+
69
+ // — Clavier sur le handle (ARIA) —
70
+ const onKeydown = (e: KeyboardEvent) => {
71
+ if (props.disable) return
72
+ const dir =
73
+ props.horizontal
74
+ ? (e.key === "ArrowUp" ? 1 : e.key === "ArrowDown" ? -1 : 0)
75
+ : (e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0)
76
+ if (dir === 0) return
77
+ e.preventDefault()
78
+ const step = e.shiftKey ? 10 : 1
79
+ const current = props.modelValue ?? 50
80
+ const rect = containerRef.value?.getBoundingClientRect()
81
+ const total = rect ? (props.horizontal ? rect.height : rect.width) : 100
82
+ const currentPct = props.unit === "%" ? current : (current / total) * 100
83
+ const nextPct = Math.max(props.limits[0], Math.min(props.limits[1], currentPct + dir * step))
84
+ emit("update:modelValue", props.unit === "%" ? nextPct : (nextPct / 100) * total)
85
+ }
86
+
87
+ const beforeStyle = computed<Record<string, string>>(() => {
88
+ const v = props.modelValue ?? 50
89
+ return { flex: `0 0 ${v}${props.unit}` }
90
+ })
91
+
92
+ const rootClasses = computed(() =>
93
+ cn(
94
+ "q-splitter",
95
+ props.horizontal && "q-splitter--horizontal",
96
+ props.reverse && "q-splitter--reverse",
97
+ props.disable && "q-splitter--disabled",
98
+ ),
99
+ )
100
+
101
+ const separatorClasses = computed(() =>
102
+ cn(
103
+ "q-splitter__separator",
104
+ dragging.value && "q-splitter__separator--dragging",
105
+ props.separatorClass,
106
+ ),
107
+ )
108
+ </script>
109
+
110
+ <template>
111
+ <div ref="containerRef" class="q-splitter" :class="rootClasses">
112
+ <div
113
+ class="q-splitter__before"
114
+ :class="beforeClass"
115
+ :style="[beforeStyle, props.beforeStyle]"
116
+ >
117
+ <slot name="before" />
118
+ </div>
119
+
120
+ <div
121
+ class="q-splitter__separator"
122
+ :class="separatorClasses"
123
+ :style="separatorStyle"
124
+ role="separator"
125
+ :aria-orientation="horizontal ? 'horizontal' : 'vertical'"
126
+ :aria-valuenow="Math.round(modelValue ?? 50)"
127
+ :aria-valuemin="limits[0]"
128
+ :aria-valuemax="limits[1]"
129
+ tabindex="0"
130
+ @pointerdown="onPointerDown"
131
+ @pointermove="onPointerMove"
132
+ @pointerup="onPointerUp"
133
+ @pointercancel="onPointerUp"
134
+ @keydown="onKeydown"
135
+ >
136
+ <slot name="separator">
137
+ <span class="q-splitter__handle" />
138
+ </slot>
139
+ </div>
140
+
141
+ <div class="q-splitter__after" :class="afterClass" :style="afterStyle">
142
+ <slot name="after" />
143
+ </div>
144
+ </div>
145
+ </template>
@@ -0,0 +1,181 @@
1
+ <script setup lang="ts">
2
+ // QSticky — colle du contenu au haut (ou bas) du viewport pendant le scroll :
3
+ // <q-sticky :offset-top="16"><q-btn … /></q-sticky>.
4
+ // Le composant garde sa place dans le flux (placeholder de même taille) et passe
5
+ // en position:fixed quand il atteint l'offset ; avec `container`, il reste
6
+ // à l'intérieur de ce conteneur (transform borné au bord bas/haut).
7
+ // Events : @scroll="{ scrollTop, isFixed }" et @change="isFixed".
8
+ import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue"
9
+
10
+ type QStickyPosition = "top" | "bottom"
11
+
12
+ interface Props {
13
+ /** Offset depuis le haut du viewport quand le composant est collé, en px (défaut 0) */
14
+ offsetTop?: number
15
+ /** Offset depuis le bas du viewport quand le composant est collé, en px (défaut 0) */
16
+ offsetBottom?: number
17
+ /** Position de collage : "top" | "bottom" (défaut top) */
18
+ position?: QStickyPosition
19
+ /** Conteneur optionnel : le composant reste à l'intérieur (s'arrête au bord du conteneur) */
20
+ container?: Element | null
21
+ /** z-index quand collé (défaut 99) */
22
+ zIndex?: number
23
+ }
24
+
25
+ const props = withDefaults(defineProps<Props>(), {
26
+ offsetTop: 0,
27
+ offsetBottom: 0,
28
+ position: "top",
29
+ container: null,
30
+ zIndex: 99,
31
+ })
32
+
33
+ const emit = defineEmits<{
34
+ scroll: [payload: { scrollTop: number; isFixed: boolean }]
35
+ change: [isFixed: boolean]
36
+ }>()
37
+
38
+ const root = ref<HTMLElement | null>(null)
39
+
40
+ const state = reactive({
41
+ fixed: false,
42
+ width: 0, // largeur mesurée
43
+ height: 0, // hauteur mesurée
44
+ transform: 0, // correction pour rester dans le conteneur
45
+ })
46
+ const isReset = ref(false)
47
+
48
+ const offset = computed(() =>
49
+ props.position === "top" ? props.offsetTop : props.offsetBottom,
50
+ )
51
+
52
+ const isHidden = () => !root.value || root.value.offsetParent === null
53
+
54
+ /** Conteneur de scroll réel le plus proche (overflow scrollable), sinon window */
55
+ const findScrollParent = (el: HTMLElement | null): HTMLElement | Window => {
56
+ let node = el?.parentElement ?? null
57
+ while (node) {
58
+ const overflowY = getComputedStyle(node).overflowY
59
+ if (
60
+ /(auto|scroll|overlay)/.test(overflowY) &&
61
+ node.scrollHeight > node.clientHeight
62
+ ) {
63
+ return node
64
+ }
65
+ node = node.parentElement
66
+ }
67
+ return window
68
+ }
69
+
70
+ let scrollParent: HTMLElement | Window = window
71
+
72
+ const scrollTop = () =>
73
+ scrollParent === window
74
+ ? window.scrollY || document.documentElement.scrollTop
75
+ : (scrollParent as HTMLElement).scrollTop
76
+
77
+ const onScroll = () => {
78
+ if (!root.value || isHidden()) return
79
+
80
+ const { container, position } = props
81
+ const rootRect = root.value.getBoundingClientRect()
82
+
83
+ state.width = rootRect.width
84
+ state.height = rootRect.height
85
+
86
+ if (position === "top") {
87
+ if (container) {
88
+ const containerRect = container.getBoundingClientRect()
89
+ const difference = containerRect.bottom - offset.value - state.height
90
+ state.fixed = offset.value > rootRect.top && containerRect.bottom > 0
91
+ state.transform = difference < 0 ? difference : 0
92
+ } else {
93
+ state.fixed = offset.value > rootRect.top
94
+ }
95
+ } else {
96
+ const clientHeight = document.documentElement.clientHeight
97
+ if (container) {
98
+ const containerRect = container.getBoundingClientRect()
99
+ const difference =
100
+ clientHeight - containerRect.top - offset.value - state.height
101
+ state.fixed =
102
+ clientHeight - offset.value < rootRect.bottom &&
103
+ clientHeight > containerRect.top
104
+ state.transform = difference < 0 ? -difference : 0
105
+ } else {
106
+ state.fixed = clientHeight - offset.value < rootRect.bottom
107
+ }
108
+ }
109
+
110
+ emit("scroll", { scrollTop: scrollTop(), isFixed: state.fixed })
111
+ }
112
+
113
+ watch(
114
+ () => state.fixed,
115
+ (value) => emit("change", value),
116
+ )
117
+
118
+ /** Re-mesure après un resize quand le composant est collé */
119
+ const onResize = () => {
120
+ if (!root.value || isHidden() || !state.fixed) return
121
+ isReset.value = true
122
+ nextTick(() => {
123
+ if (!root.value) return
124
+ const r = root.value.getBoundingClientRect()
125
+ state.width = r.width
126
+ state.height = r.height
127
+ isReset.value = false
128
+ })
129
+ }
130
+
131
+ onMounted(() => {
132
+ if (root.value) {
133
+ scrollParent = findScrollParent(root.value)
134
+ scrollParent.addEventListener("scroll", onScroll, { passive: true })
135
+ window.addEventListener("resize", onResize)
136
+ onScroll()
137
+ }
138
+ })
139
+
140
+ onBeforeUnmount(() => {
141
+ scrollParent.removeEventListener("scroll", onScroll)
142
+ window.removeEventListener("resize", onResize)
143
+ })
144
+
145
+ // Placeholder : conserve la place du contenu dans le flux quand il est collé
146
+ const rootStyle = computed(() => {
147
+ if (isReset.value || !state.fixed) return undefined
148
+ return { width: `${state.width}px`, height: `${state.height}px` }
149
+ })
150
+
151
+ const stickyStyle = computed(() => {
152
+ if (!state.fixed || isReset.value) return undefined
153
+ const style: Record<string, string> = {
154
+ width: `${state.width}px`,
155
+ height: `${state.height}px`,
156
+ [props.position]: `${offset.value}px`,
157
+ "--q-sticky-z-index": String(props.zIndex),
158
+ }
159
+ if (state.transform) style.transform = `translate3d(0, ${state.transform}px, 0)`
160
+ return style
161
+ })
162
+ </script>
163
+
164
+ <template>
165
+ <div ref="root" class="q-sticky" :style="rootStyle">
166
+ <div
167
+ class="q-sticky__content"
168
+ :class="{ 'q-sticky__content--fixed': state.fixed && !isReset }"
169
+ :style="stickyStyle"
170
+ >
171
+ <slot />
172
+ </div>
173
+ </div>
174
+ </template>
175
+
176
+ <style scoped>
177
+ .q-sticky__content--fixed {
178
+ position: fixed;
179
+ z-index: var(--q-sticky-z-index, 99);
180
+ }
181
+ </style>