@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,289 @@
1
+ <script setup lang="ts">
2
+ // QVideo — lecteur vidéo basé sur le framework HTML de Video.js v10 (@videojs/html) :
3
+ // <q-video src="movie.mp4" poster="…" ratio="16/9" />
4
+ // Custom elements composables : <video-player> > <video-skin> > media element.
5
+ // Le média est choisi selon la source : <video> (mp4/webm/ogg), <youtube-video>
6
+ // (YouTube), <hlsjs-video> (.m3u8) — mux-video est aussi enregistré (prêt à l'emploi).
7
+ // Les modules sont chargés uniquement côté client (SSR safe — customElements).
8
+ // Events : @ready (media element), @play, @pause, @ended, @timeupdate, @loadedmetadata, @volumechange.
9
+ // Méthodes exposées : play / pause / togglePlay / seek / getCurrentTime / getDuration / setVolume / setMuted / isPlaying.
10
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"
11
+ import "@videojs/html/video/skin.css"
12
+
13
+ interface Props {
14
+ /** URL de la vidéo (mp4, webm, ogg, HLS .m3u8, YouTube…) */
15
+ src?: string
16
+ /** Image d'affiche avant lecture */
17
+ poster?: string
18
+ /** Image de remplacement affichée tant que la vidéo n'est pas chargée (ou slot #placeholder) */
19
+ placeholder?: string
20
+ /** Texte alternatif de l'image placeholder (défaut "Video placeholder") */
21
+ placeholderAlt?: string
22
+ /** Affiche les contrôles (défaut true). Le skin par défaut gère ses contrôles
23
+ * (état visible/masqué pendant la lecture). */
24
+ controls?: boolean
25
+ /** Démarre la lecture automatiquement (souvent combiné avec muted) */
26
+ autoplay?: boolean
27
+ /** Boucle à la fin */
28
+ loop?: boolean
29
+ /** Muet */
30
+ muted?: boolean
31
+ /** Chargement : "auto" | "metadata" (défaut) | "none" */
32
+ preload?: string
33
+ /** Ratio d'affichage : "16/9" (défaut), "4/3", "1/1", "none" (hauteur auto) */
34
+ ratio?: string
35
+ /** Largeur max du lecteur (défaut 100%) */
36
+ width?: string
37
+ }
38
+
39
+ const props = withDefaults(defineProps<Props>(), {
40
+ src: "",
41
+ poster: "",
42
+ placeholder: "",
43
+ placeholderAlt: "Video placeholder",
44
+ controls: true,
45
+ autoplay: false,
46
+ loop: false,
47
+ muted: false,
48
+ preload: "metadata",
49
+ ratio: "16/9",
50
+ width: "",
51
+ })
52
+
53
+ const emit = defineEmits<{
54
+ ready: [media: any]
55
+ play: []
56
+ pause: []
57
+ ended: []
58
+ timeupdate: [payload: { currentTime: number; duration: number }]
59
+ loadedmetadata: [payload: { duration: number }]
60
+ volumechange: [payload: { muted: boolean; volume: number }]
61
+ }>()
62
+
63
+ /** URL YouTube (watch, shorts, embed, youtu.be) */
64
+ const isYoutube = computed(() =>
65
+ /(youtube\.com\/(watch\?v=|shorts\/|embed\/)|youtu\.be\/)/.test(props.src),
66
+ )
67
+ /** Flux HLS (.m3u8) → media element hlsjs-video */
68
+ const isHls = computed(() => /\.m3u8(\?|$)/.test(props.src))
69
+
70
+ const mediaEl = ref<HTMLElement | null>(null)
71
+ const playing = ref(false)
72
+ /** true dès que le média a chargé ses métadonnées (ou qu'il joue) → masque le placeholder */
73
+ const metaLoaded = ref(false)
74
+
75
+ const media = (): any => mediaEl.value
76
+
77
+ const onPlay = () => {
78
+ metaLoaded.value = true
79
+ playing.value = true
80
+ emit("play")
81
+ }
82
+ const onPause = () => {
83
+ playing.value = false
84
+ emit("pause")
85
+ }
86
+ const onEnded = () => emit("ended")
87
+ const onTimeupdate = () =>
88
+ emit("timeupdate", { currentTime: media()?.currentTime ?? 0, duration: media()?.duration ?? 0 })
89
+ const onLoadedmetadata = () => {
90
+ metaLoaded.value = true
91
+ emit("loadedmetadata", { duration: media()?.duration ?? 0 })
92
+ }
93
+ const onVolumechange = () =>
94
+ emit("volumechange", { muted: media()?.muted ?? false, volume: media()?.volume ?? 1 })
95
+
96
+ const listeners: Array<[string, EventListener]> = [
97
+ ["play", onPlay],
98
+ ["pause", onPause],
99
+ ["ended", onEnded],
100
+ ["timeupdate", onTimeupdate],
101
+ ["loadedmetadata", onLoadedmetadata],
102
+ ["volumechange", onVolumechange],
103
+ ]
104
+
105
+ const attach = (el: HTMLElement) => {
106
+ for (const [type, fn] of listeners) el.addEventListener(type, fn)
107
+ }
108
+
109
+ const detach = (el: HTMLElement | null) => {
110
+ if (!el) return
111
+ for (const [type, fn] of listeners) el.removeEventListener(type, fn)
112
+ }
113
+
114
+ // Attache les listeners dès que le media element existe (et à chaque changement de
115
+ // type de média) — sinon loadedmetadata est émis avant l'init et le placeholder
116
+ // resterait affiché, bloquant les contrôles.
117
+ watch(mediaEl, (el, old) => {
118
+ detach(old ?? null)
119
+ if (el) attach(el)
120
+ }, { immediate: true })
121
+
122
+ // Clic sur le placeholder : masque-le et lance la lecture
123
+ const onPlaceholderClick = () => {
124
+ metaLoaded.value = true
125
+ play()
126
+ }
127
+
128
+ onMounted(async () => {
129
+ // Charge le framework HTML (custom elements) — uniquement côté client
130
+ await Promise.all([
131
+ import("@videojs/html/video/player" as string),
132
+ import("@videojs/html/video/skin" as string),
133
+ import("@videojs/html/media/mux-video" as string),
134
+ import("@videojs/html/media/youtube-video" as string),
135
+ import("@videojs/html/media/hlsjs-video" as string),
136
+ ])
137
+ if (typeof customElements !== "undefined") {
138
+ await customElements.whenDefined("video-player")
139
+ }
140
+ emit("ready", mediaEl.value)
141
+ })
142
+
143
+ onBeforeUnmount(() => detach(mediaEl.value ?? null))
144
+
145
+ // — Méthodes exposées —
146
+ const play = () => media()?.play()
147
+ const pause = () => media()?.pause()
148
+ const togglePlay = () => {
149
+ const el = media()
150
+ if (!el) return
151
+ if (el.paused) el.play()
152
+ else el.pause()
153
+ }
154
+ const seek = (t: number) => {
155
+ if (media()) media().currentTime = t
156
+ }
157
+ const getCurrentTime = () => media()?.currentTime ?? 0
158
+ const getDuration = () => media()?.duration ?? 0
159
+ const setVolume = (v: number) => {
160
+ if (media()) media().volume = v
161
+ }
162
+ const setMuted = (m: boolean) => {
163
+ if (media()) media().muted = m
164
+ }
165
+ const isPlaying = () => playing.value
166
+
167
+ defineExpose({
168
+ play,
169
+ pause,
170
+ togglePlay,
171
+ seek,
172
+ getCurrentTime,
173
+ getDuration,
174
+ setVolume,
175
+ setMuted,
176
+ isPlaying,
177
+ })
178
+
179
+ const wrapperStyle = (): Record<string, string> => {
180
+ const style: Record<string, string> = {}
181
+ if (props.width) style.maxWidth = props.width
182
+ if (props.ratio !== "none") style.aspectRatio = props.ratio.replace("/", " / ")
183
+ else style.aspectRatio = "auto"
184
+ return style
185
+ }
186
+ </script>
187
+
188
+ <template>
189
+ <div class="q-video" :style="wrapperStyle()">
190
+ <!-- Custom elements @videojs/html : player > skin > media -->
191
+ <video-player class="q-video__player">
192
+ <video-skin>
193
+ <video
194
+ v-if="!isYoutube && !isHls"
195
+ ref="mediaEl"
196
+ :src="src"
197
+ :poster="poster"
198
+ :autoplay="autoplay"
199
+ :loop="loop"
200
+ :muted="muted"
201
+ :preload="preload"
202
+ playsinline
203
+ />
204
+ <youtube-video
205
+ v-else-if="isYoutube"
206
+ ref="mediaEl"
207
+ :src="src"
208
+ :poster="poster"
209
+ :autoplay="autoplay"
210
+ :loop="loop"
211
+ :muted="muted"
212
+ playsinline
213
+ />
214
+ <hlsjs-video
215
+ v-else
216
+ ref="mediaEl"
217
+ :src="src"
218
+ :poster="poster"
219
+ :autoplay="autoplay"
220
+ :loop="loop"
221
+ :muted="muted"
222
+ playsinline
223
+ />
224
+ </video-skin>
225
+ </video-player>
226
+
227
+ <!-- Placeholder tant que la vidéo n'est pas chargée (clic = lecture) -->
228
+ <div
229
+ v-if="!metaLoaded && (placeholder || $slots.placeholder)"
230
+ class="q-video__placeholder"
231
+ role="button"
232
+ :aria-label="placeholderAlt"
233
+ @click="onPlaceholderClick"
234
+ >
235
+ <img v-if="placeholder" :src="placeholder" :alt="placeholderAlt" />
236
+ <slot name="placeholder" />
237
+ </div>
238
+ </div>
239
+ </template>
240
+
241
+ <style scoped>
242
+ .q-video {
243
+ position: relative;
244
+ width: 100%;
245
+ overflow: hidden;
246
+ border-radius: var(--q-radius, 12px);
247
+ background: #000;
248
+ }
249
+ /* Avant l'upgrade des custom elements : display block + remplir le cadre */
250
+ .q-video video-player,
251
+ .q-video video-skin,
252
+ .q-video video,
253
+ .q-video youtube-video,
254
+ .q-video hlsjs-video {
255
+ display: block;
256
+ }
257
+ .q-video video-player,
258
+ .q-video video-skin {
259
+ position: absolute;
260
+ inset: 0;
261
+ width: 100% !important;
262
+ height: 100% !important;
263
+ }
264
+ .q-video video,
265
+ .q-video youtube-video,
266
+ .q-video hlsjs-video {
267
+ width: 100%;
268
+ height: 100%;
269
+ object-fit: contain;
270
+ }
271
+
272
+ /* Placeholder de chargement : image ou contenu, cliquable pour lancer la lecture */
273
+ .q-video__placeholder {
274
+ position: absolute;
275
+ inset: 0;
276
+ z-index: 1;
277
+ display: flex;
278
+ align-items: center;
279
+ justify-content: center;
280
+ overflow: hidden;
281
+ cursor: pointer;
282
+ background: #000;
283
+ }
284
+ .q-video__placeholder img {
285
+ width: 100%;
286
+ height: 100%;
287
+ object-fit: cover;
288
+ }
289
+ </style>
@@ -0,0 +1,190 @@
1
+ <script setup lang="ts">
2
+ // QVirtualScroll — API Quasar : <q-virtual-scroll :items="rows" item-key="id" virtual-scroll-slice-size="12" />
3
+ // Ne rend que la fenêtre visible (+ marges) ; hauteurs réelles mesurées par ResizeObserver
4
+ // (slot scoped : { item, index, ref }) — le consommateur bind :ref sur son élément.
5
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"
6
+
7
+ interface Props {
8
+ /** Données à afficher */
9
+ items?: any[]
10
+ /** Clé d'item : string (propriété) ou fonction */
11
+ itemKey?: string | ((item: any) => unknown)
12
+ /** Nombre d'items rendus autour de la zone visible */
13
+ virtualScrollSliceSize?: number
14
+ /** Ratio d'items rendus avant la zone visible */
15
+ virtualScrollSliceRatioBefore?: number
16
+ /** Ratio d'items rendus après la zone visible */
17
+ virtualScrollSliceRatioAfter?: number
18
+ /** Taille moyenne d'un item en px (estimation initiale) */
19
+ virtualScrollItemSize?: number
20
+ /** Espace réservé en haut (contenu sticky) */
21
+ virtualScrollStickySizeStart?: number
22
+ /** Espace réservé en bas (contenu sticky) */
23
+ virtualScrollStickySizeEnd?: number
24
+ }
25
+
26
+ const props = withDefaults(defineProps<Props>(), {
27
+ items: () => [],
28
+ itemKey: "id",
29
+ virtualScrollSliceSize: 14,
30
+ virtualScrollSliceRatioBefore: 1,
31
+ virtualScrollSliceRatioAfter: 1,
32
+ virtualScrollItemSize: 24,
33
+ virtualScrollStickySizeStart: 0,
34
+ virtualScrollStickySizeEnd: 0,
35
+ })
36
+
37
+ defineSlots<{
38
+ default?: (props: { item: any; index: number; ref: (el: Element | null) => void }) => any
39
+ before?: () => any
40
+ after?: () => any
41
+ }>()
42
+
43
+ const itemSize = () => props.virtualScrollItemSize
44
+ const sliceBefore = computed(() => Math.ceil(props.virtualScrollSliceSize * props.virtualScrollSliceRatioBefore))
45
+ const sliceAfter = computed(() => Math.ceil(props.virtualScrollSliceSize * props.virtualScrollSliceRatioAfter))
46
+
47
+ const scrollTop = ref(0)
48
+
49
+ // — Hauteurs mesurées par index (celles non mesurées → taille estimée) —
50
+ const itemSizes = new Map<number, number>()
51
+ const observers = new Map<number, ResizeObserver>()
52
+ const refFns = new Map<number, (el: Element | null) => void>()
53
+
54
+ // Préfixe cumulé des offsets (reconstruit quand les tailles changent, pas au scroll)
55
+ let prefix: number[] = [0]
56
+ let total = 0
57
+ let rafPending = false
58
+
59
+ const rebuildPrefix = () => {
60
+ let acc = 0
61
+ prefix = new Array(props.items.length + 1)
62
+ prefix[0] = 0
63
+ for (let i = 0; i < props.items.length; i++) {
64
+ acc += itemSizes.get(i) ?? itemSize()
65
+ prefix[i + 1] = acc
66
+ }
67
+ total = acc
68
+ }
69
+
70
+ const scheduleRebuild = () => {
71
+ if (rafPending) return
72
+ rafPending = true
73
+ requestAnimationFrame(() => {
74
+ rafPending = false
75
+ rebuildPrefix()
76
+ })
77
+ }
78
+
79
+ const observeItem = (index: number, el: Element | null) => {
80
+ if (el === null) {
81
+ observers.get(index)?.disconnect()
82
+ observers.delete(index)
83
+ return
84
+ }
85
+ if (observers.has(index)) return
86
+ const ro = new ResizeObserver(() => {
87
+ const h = el.getBoundingClientRect().height
88
+ if (h > 0 && itemSizes.get(index) !== h) {
89
+ itemSizes.set(index, h)
90
+ scheduleRebuild()
91
+ }
92
+ })
93
+ ro.observe(el)
94
+ observers.set(index, ro)
95
+ const h = el.getBoundingClientRect().height
96
+ if (h > 0) {
97
+ itemSizes.set(index, h)
98
+ scheduleRebuild()
99
+ }
100
+ }
101
+
102
+ /** Fonction de ref stable par index (évite de re-créer la closure à chaque rendu) */
103
+ const itemRef = (index: number) => {
104
+ let fn = refFns.get(index)
105
+ if (!fn) {
106
+ fn = (el) => observeItem(index, el)
107
+ refFns.set(index, fn)
108
+ }
109
+ return fn
110
+ }
111
+
112
+ const clearObservers = () => {
113
+ for (const ro of observers.values()) ro.disconnect()
114
+ observers.clear()
115
+ refFns.clear()
116
+ }
117
+
118
+ // — Fenêtrage —
119
+ const findFirst = (pos: number): number => {
120
+ let lo = 0
121
+ let hi = prefix.length - 1
122
+ while (lo < hi) {
123
+ const mid = (lo + hi + 1) >> 1
124
+ if (prefix[mid]! <= pos) lo = mid
125
+ else hi = mid - 1
126
+ }
127
+ return lo
128
+ }
129
+
130
+ const start = computed(() => {
131
+ if (props.items.length === 0) return 0
132
+ return Math.max(0, findFirst(scrollTop.value) - sliceBefore.value)
133
+ })
134
+
135
+ const end = computed(() =>
136
+ Math.min(props.items.length, start.value + props.virtualScrollSliceSize + sliceAfter.value),
137
+ )
138
+
139
+ const renderedItems = computed(() => props.items.slice(start.value, end.value))
140
+
141
+ const offsetOf = (index: number) => prefix[index] ?? 0
142
+
143
+ const sliceY = computed(() => props.virtualScrollStickySizeStart + offsetOf(start.value))
144
+ const spacerH = computed(
145
+ () => props.virtualScrollStickySizeStart + total + props.virtualScrollStickySizeEnd,
146
+ )
147
+
148
+ const onScroll = (e: Event) => {
149
+ scrollTop.value = (e.target as HTMLElement).scrollTop
150
+ }
151
+
152
+ const getKey = (item: any): string | number | symbol | undefined => {
153
+ if (typeof props.itemKey === "function") return props.itemKey(item) as string | number | symbol
154
+ if (props.itemKey) return (item?.[props.itemKey] ?? undefined) as string | number | symbol | undefined
155
+ return item?.id ?? item?.key
156
+ }
157
+
158
+ watch(
159
+ () => props.items,
160
+ (val, old) => {
161
+ if (val !== old) {
162
+ itemSizes.clear()
163
+ clearObservers()
164
+ }
165
+ rebuildPrefix()
166
+ },
167
+ { immediate: true },
168
+ )
169
+
170
+ watch(() => props.virtualScrollItemSize, rebuildPrefix)
171
+
172
+ onMounted(() => rebuildPrefix())
173
+ onBeforeUnmount(clearObservers)
174
+ </script>
175
+
176
+ <template>
177
+ <div class="q-virtual-scroll" @scroll.passive="onScroll">
178
+ <div class="q-virtual-scroll__content">
179
+ <slot name="before" />
180
+ <div class="q-virtual-scroll__spacer" :style="{ height: `${spacerH}px` }">
181
+ <div class="q-virtual-scroll__slice" :style="{ transform: `translateY(${sliceY}px)` }">
182
+ <template v-for="(item, off) in renderedItems" :key="getKey(item)">
183
+ <slot :item="item" :index="start + off" :ref="itemRef(start + off)" />
184
+ </template>
185
+ </div>
186
+ </div>
187
+ <slot name="after" />
188
+ </div>
189
+ </div>
190
+ </template>
@@ -0,0 +1,153 @@
1
+ <script setup lang="ts">
2
+ // QDateCalendar — calendrier interne (non exporté) utilisé par QDatePicker (inline + panneau).
3
+ import { computed, ref, watch } from "vue"
4
+ import { Icon } from "@iconify/vue"
5
+ import { icons } from "../../lib/icons"
6
+ import { cn } from "../../lib/utils"
7
+
8
+ interface Props {
9
+ modelValue?: Date | null
10
+ /** Date minimale sélectionnable */
11
+ minDate?: Date | string
12
+ /** Date maximale sélectionnable */
13
+ maxDate?: Date | string
14
+ /** Désactive des dates arbitraires */
15
+ disabledDates?: (d: Date) => boolean
16
+ /** Premier jour de la semaine : 0 = dimanche, 1 = lundi (défaut) */
17
+ firstDayOfWeek?: number
18
+ }
19
+
20
+ const props = withDefaults(defineProps<Props>(), {
21
+ modelValue: null,
22
+ firstDayOfWeek: 1,
23
+ })
24
+
25
+ const emit = defineEmits<{ select: [date: Date] }>()
26
+
27
+ const today = new Date()
28
+ const viewYear = ref(props.modelValue?.getFullYear() ?? today.getFullYear())
29
+ const viewMonth = ref(props.modelValue?.getMonth() ?? today.getMonth())
30
+
31
+ watch(
32
+ () => props.modelValue,
33
+ (v) => {
34
+ if (v) {
35
+ viewYear.value = v.getFullYear()
36
+ viewMonth.value = v.getMonth()
37
+ }
38
+ },
39
+ )
40
+
41
+ const toDate = (d: Date | string) => (d instanceof Date ? d : new Date(d))
42
+ const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate())
43
+ const sameDay = (a: Date, b: Date) =>
44
+ a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
45
+
46
+ const weekdayLabels = computed(() => {
47
+ const names = ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"] as const
48
+ return Array.from({ length: 7 }, (_, i) => names[(props.firstDayOfWeek + i) % 7])
49
+ })
50
+
51
+ const weeks = computed<(Date | null)[][]>(() => {
52
+ const y = viewYear.value
53
+ const m = viewMonth.value
54
+ const offset = (new Date(y, m, 1).getDay() - props.firstDayOfWeek + 7) % 7
55
+ const dim = new Date(y, m + 1, 0).getDate()
56
+ const cells: (Date | null)[] = []
57
+ for (let i = 0; i < offset; i++) cells.push(null)
58
+ for (let d = 1; d <= dim; d++) cells.push(new Date(y, m, d))
59
+ while (cells.length % 7 !== 0) cells.push(null)
60
+ const result: (Date | null)[][] = []
61
+ for (let i = 0; i < cells.length; i += 7) result.push(cells.slice(i, i + 7))
62
+ return result
63
+ })
64
+
65
+ const monthLabel = computed(() =>
66
+ new Intl.DateTimeFormat(undefined, { month: "long", year: "numeric" }).format(
67
+ new Date(viewYear.value, viewMonth.value, 1),
68
+ ),
69
+ )
70
+
71
+ const prevMonth = () => {
72
+ if (viewMonth.value === 0) {
73
+ viewMonth.value = 11
74
+ viewYear.value--
75
+ }
76
+ else {
77
+ viewMonth.value--
78
+ }
79
+ }
80
+
81
+ const nextMonth = () => {
82
+ if (viewMonth.value === 11) {
83
+ viewMonth.value = 0
84
+ viewYear.value++
85
+ }
86
+ else {
87
+ viewMonth.value++
88
+ }
89
+ }
90
+
91
+ const isSelected = (d: Date) => !!props.modelValue && sameDay(props.modelValue, d)
92
+ const isToday = (d: Date) => sameDay(d, new Date())
93
+
94
+ const isDisabled = (d: Date) => {
95
+ if (props.minDate && d < startOfDay(toDate(props.minDate))) return true
96
+ if (props.maxDate && d > startOfDay(toDate(props.maxDate))) return true
97
+ return !!props.disabledDates?.(d)
98
+ }
99
+
100
+ const onSelectDate = (d: Date) => {
101
+ if (isDisabled(d)) return
102
+ emit("select", d)
103
+ }
104
+ </script>
105
+
106
+ <template>
107
+ <div class="q-date-calendar">
108
+ <div class="q-date-calendar__nav">
109
+ <button
110
+ type="button"
111
+ class="q-date-calendar__nav-btn"
112
+ aria-label="Mois précédent"
113
+ @click="prevMonth"
114
+ >
115
+ <Icon :icon="icons.chevronLeft" aria-hidden="true" />
116
+ </button>
117
+ <span class="q-date-calendar__nav-label">{{ monthLabel }}</span>
118
+ <button
119
+ type="button"
120
+ class="q-date-calendar__nav-btn"
121
+ aria-label="Mois suivant"
122
+ @click="nextMonth"
123
+ >
124
+ <Icon :icon="icons.chevronRight" aria-hidden="true" />
125
+ </button>
126
+ </div>
127
+
128
+ <div class="q-date-calendar__weekdays">
129
+ <span v-for="(d, i) in weekdayLabels" :key="i" class="q-date-calendar__weekday">{{ d }}</span>
130
+ </div>
131
+
132
+ <div class="q-date-calendar__weeks">
133
+ <div v-for="(week, wi) in weeks" :key="wi" class="q-date-calendar__week">
134
+ <button
135
+ v-for="(d, di) in week"
136
+ :key="di"
137
+ type="button"
138
+ class="q-date-calendar__day"
139
+ :class="cn(
140
+ !d && 'q-date-calendar__day--empty',
141
+ !!d && isToday(d) && 'q-date-calendar__day--today',
142
+ !!d && isSelected(d) && 'q-date-calendar__day--selected',
143
+ )"
144
+ :disabled="!d || isDisabled(d)"
145
+ :aria-selected="!!d && isSelected(d) ? 'true' : 'false'"
146
+ @click="d && onSelectDate(d)"
147
+ >
148
+ {{ d ? d.getDate() : "" }}
149
+ </button>
150
+ </div>
151
+ </div>
152
+ </div>
153
+ </template>