@dnax/ui 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/components/QAccordion.vue +68 -0
- package/components/QAccordionContent.vue +62 -0
- package/components/QAccordionItem.vue +24 -0
- package/components/QAccordionTrigger.vue +51 -0
- package/components/QActionSheet.vue +159 -0
- package/components/QApp.vue +8 -0
- package/components/QAutocomplete.vue +316 -0
- package/components/QAvatar.vue +92 -0
- package/components/QBackHeader.vue +69 -0
- package/components/QBackTop.vue +125 -0
- package/components/QBadge.vue +55 -0
- package/components/QBottomSheet.vue +177 -0
- package/components/QBottomSheetFooter.vue +9 -0
- package/components/QBottomSheetHeader.vue +35 -0
- package/components/QBottomSheetProvider.vue +62 -0
- package/components/QBottomSheetTrigger.vue +18 -0
- package/components/QBtn.vue +144 -0
- package/components/QBtnGroup.vue +31 -0
- package/components/QBubble.vue +36 -0
- package/components/QBubbleContent.vue +28 -0
- package/components/QBubbleGroup.vue +25 -0
- package/components/QBubbleReactions.vue +40 -0
- package/components/QCard.vue +63 -0
- package/components/QCardActions.vue +27 -0
- package/components/QCardContent.vue +29 -0
- package/components/QCarousel.vue +115 -0
- package/components/QCarouselContent.vue +23 -0
- package/components/QCarouselItem.vue +20 -0
- package/components/QCarouselNav.vue +51 -0
- package/components/QCheckbox.vue +146 -0
- package/components/QChip.vue +91 -0
- package/components/QCircularProgress.vue +122 -0
- package/components/QCol.vue +52 -0
- package/components/QCollapse.vue +141 -0
- package/components/QConfigProvider.vue +164 -0
- package/components/QContainer.vue +201 -0
- package/components/QCountDown.vue +164 -0
- package/components/QCountryPicker.vue +593 -0
- package/components/QDataGrid.vue +491 -0
- package/components/QDatePicker.vue +250 -0
- package/components/QDialog.vue +163 -0
- package/components/QDialogFooter.vue +9 -0
- package/components/QDialogHeader.vue +41 -0
- package/components/QDialogProvider.vue +65 -0
- package/components/QDialogTrigger.vue +18 -0
- package/components/QFab.vue +80 -0
- package/components/QFabAction.vue +48 -0
- package/components/QFilePicker.vue +294 -0
- package/components/QFooter.vue +72 -0
- package/components/QGallery.vue +280 -0
- package/components/QGrid.vue +54 -0
- package/components/QHeader.vue +38 -0
- package/components/QIcon.vue +53 -0
- package/components/QImagePicker.vue +248 -0
- package/components/QImagePreview.vue +225 -0
- package/components/QImagePreviewProvider.vue +35 -0
- package/components/QImg.vue +142 -0
- package/components/QInfiniteScroll.vue +109 -0
- package/components/QInnerLoading.vue +67 -0
- package/components/QInput.vue +289 -0
- package/components/QInputOtp.vue +238 -0
- package/components/QInputPassword.vue +67 -0
- package/components/QInputTag.vue +281 -0
- package/components/QIntersection.vue +119 -0
- package/components/QItem.vue +60 -0
- package/components/QItemSection.vue +47 -0
- package/components/QLinearProgress.vue +94 -0
- package/components/QList.vue +39 -0
- package/components/QLoading.vue +77 -0
- package/components/QLoadingProvider.vue +41 -0
- package/components/QMarquee.vue +56 -0
- package/components/QMessageScroller.vue +10 -0
- package/components/QMessageScrollerButton.vue +48 -0
- package/components/QMessageScrollerContent.vue +31 -0
- package/components/QMessageScrollerItem.vue +49 -0
- package/components/QMessageScrollerProvider.vue +198 -0
- package/components/QMessageScrollerViewport.vue +109 -0
- package/components/QNavMenu.vue +75 -0
- package/components/QNavMenuContent.vue +10 -0
- package/components/QNavMenuItem.vue +60 -0
- package/components/QNavMenuTrigger.vue +96 -0
- package/components/QNotifyProvider.vue +33 -0
- package/components/QNotifyToast.vue +60 -0
- package/components/QPage.vue +56 -0
- package/components/QPagination.vue +195 -0
- package/components/QParallax.vue +124 -0
- package/components/QPullToRefresh.vue +138 -0
- package/components/QRadio.vue +99 -0
- package/components/QRating.vue +105 -0
- package/components/QReorder.vue +191 -0
- package/components/QRollingText.vue +225 -0
- package/components/QRow.vue +23 -0
- package/components/QSafeArea.vue +39 -0
- package/components/QScrollArea.vue +118 -0
- package/components/QSelect.vue +698 -0
- package/components/QSeparator.vue +39 -0
- package/components/QSidebar.vue +153 -0
- package/components/QSidebarContent.vue +14 -0
- package/components/QSidebarFooter.vue +14 -0
- package/components/QSidebarHeader.vue +14 -0
- package/components/QSidebarMenu.vue +9 -0
- package/components/QSidebarMenuButton.vue +59 -0
- package/components/QSidebarMenuItem.vue +9 -0
- package/components/QSidebarTrigger.vue +32 -0
- package/components/QSkeleton.vue +95 -0
- package/components/QSlider.vue +205 -0
- package/components/QSpace.vue +10 -0
- package/components/QSpinner.vue +172 -0
- package/components/QSplitter.vue +145 -0
- package/components/QSticky.vue +181 -0
- package/components/QSwipeCell.vue +217 -0
- package/components/QSwiper.vue +102 -0
- package/components/QSwiperSlide.vue +19 -0
- package/components/QSyntax.vue +171 -0
- package/components/QTab.vue +175 -0
- package/components/QTabPanel.vue +50 -0
- package/components/QTabPanels.vue +106 -0
- package/components/QTable.vue +575 -0
- package/components/QTabs.vue +188 -0
- package/components/QText.vue +316 -0
- package/components/QTextCaption.vue +122 -0
- package/components/QToolbar.vue +28 -0
- package/components/QTooltip.vue +313 -0
- package/components/QUploader.vue +473 -0
- package/components/QVideo.vue +289 -0
- package/components/QVirtualScroll.vue +190 -0
- package/components/internal/QDateCalendar.vue +153 -0
- package/index.ts +151 -0
- package/lib/breakpoints.ts +44 -0
- package/lib/colors.ts +24 -0
- package/lib/config.ts +34 -0
- package/lib/countries.ts +261 -0
- package/lib/formula.ts +630 -0
- package/lib/icons.ts +38 -0
- package/lib/loading.ts +75 -0
- package/lib/overlayBack.ts +124 -0
- package/lib/platform.ts +146 -0
- package/lib/primitive.ts +25 -0
- package/lib/q.ts +287 -0
- package/lib/screen.ts +105 -0
- package/lib/search.ts +35 -0
- package/lib/themeVars.ts +22 -0
- package/lib/useComponentProps.ts +54 -0
- package/lib/utils.ts +7 -0
- package/module.ts +89 -0
- package/package.json +41 -0
- package/scripts/generate-exports.ts +56 -0
- package/styles/main.css +7627 -0
- package/tsconfig.json +33 -0
|
@@ -0,0 +1,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>
|