@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,141 @@
1
+ <script setup lang="ts">
2
+ // QCollapse — API Quasar (pattern QExpansionItem) : <q-collapse v-model label="…" icon-left="…" icon-right="…" caption dense>
3
+ // Équivalent du Collapsible shadcn-vue : animation de hauteur via CSS grid (0fr → 1fr).
4
+ import { computed, nextTick, onMounted, ref, watch } from "vue"
5
+ import { Icon } from "@iconify/vue"
6
+ import { icons } from "../lib/icons"
7
+ import { cn } from "../lib/utils"
8
+
9
+ let collapseSeq = 0
10
+
11
+ interface Props {
12
+ /** État ouvert (v-model) ; si absent, état interne initialisé par defaultOpened */
13
+ modelValue?: boolean
14
+ /** Ouvert par défaut (mode non contrôlé) */
15
+ defaultOpened?: boolean
16
+ /** Texte du header */
17
+ label?: string
18
+ /** Sous-titre du header */
19
+ caption?: string
20
+ /** Icône Iconify à gauche du label */
21
+ iconLeft?: string
22
+ /** Icône Iconify à droite du label (avant le chevron) */
23
+ iconRight?: string
24
+ /** Icône Iconify du chevron (défaut : lucide:chevron-down) */
25
+ expandIcon?: string
26
+ headerClass?: string
27
+ /** Hauteur réduite */
28
+ dense?: boolean
29
+ /** Désactivé */
30
+ disable?: boolean
31
+ }
32
+
33
+ const props = withDefaults(defineProps<Props>(), {
34
+ defaultOpened: false,
35
+ dense: false,
36
+ disable: false,
37
+ headerClass: "",
38
+ })
39
+
40
+ const emit = defineEmits<{ "update:modelValue": [value: boolean] }>()
41
+
42
+ const uid = `q-collapse-${collapseSeq++}`
43
+
44
+ const internalOpen = ref(props.defaultOpened)
45
+ const open = computed({
46
+ get: () => props.modelValue ?? internalOpen.value,
47
+ set: (v) => {
48
+ internalOpen.value = v
49
+ emit("update:modelValue", v)
50
+ },
51
+ })
52
+
53
+ const toggle = () => {
54
+ if (props.disable) return
55
+ open.value = !open.value
56
+ }
57
+
58
+ const headerClasses = computed(() =>
59
+ cn(
60
+ "q-collapse__header",
61
+ props.dense && "q-collapse__header--dense",
62
+ props.disable && "q-collapse__header--disabled",
63
+ props.headerClass,
64
+ ),
65
+ )
66
+
67
+ // — Contenu repliable : hauteur mesurée (pattern shadcn/reka-ui) —
68
+ const rootEl = ref<HTMLElement | null>(null)
69
+ const innerEl = ref<HTMLElement | null>(null)
70
+ const contentHeight = ref(0)
71
+ const measured = ref(false)
72
+
73
+ const measure = () => {
74
+ const inner = innerEl.value
75
+ if (inner) {
76
+ contentHeight.value = inner.scrollHeight
77
+ measured.value = true
78
+ }
79
+ }
80
+
81
+ const contentStyle = computed<Record<string, string> | undefined>(() => {
82
+ if (!open.value) return { height: "0px" }
83
+ return measured.value ? { height: `${contentHeight.value}px` } : undefined
84
+ })
85
+
86
+ watch(open, async (v) => {
87
+ if (v) {
88
+ await nextTick()
89
+ measure()
90
+ }
91
+ })
92
+
93
+ const onTransitionEnd = () => {
94
+ if (open.value && rootEl.value) rootEl.value.style.height = "auto"
95
+ }
96
+
97
+ onMounted(measure)
98
+ </script>
99
+
100
+ <template>
101
+ <div class="q-collapse">
102
+ <button
103
+ type="button"
104
+ class="q-collapse__header"
105
+ :class="headerClasses"
106
+ :aria-expanded="open ? 'true' : 'false'"
107
+ :aria-controls="uid"
108
+ :disabled="disable"
109
+ @click="toggle"
110
+ >
111
+ <span class="q-collapse__header-inner">
112
+ <slot name="header">
113
+ <Icon :icon="iconLeft" v-if="iconLeft" class="q-collapse__lead-icon" aria-hidden="true" />
114
+ <span class="q-collapse__titles">
115
+ <span v-if="label" class="q-collapse__label">{{ label }}</span>
116
+ <span v-if="caption" class="q-collapse__caption">{{ caption }}</span>
117
+ </span>
118
+ <Icon :icon="iconRight" v-if="iconRight" class="q-collapse__icon-right" aria-hidden="true" />
119
+ </slot>
120
+ </span>
121
+ <Icon
122
+ :icon="expandIcon || icons.chevronDown"
123
+ class="q-collapse__expand"
124
+ :class="{ 'q-collapse__expand--rotated': open }"
125
+ aria-hidden="true"
126
+ />
127
+ </button>
128
+ <div
129
+ ref="rootEl"
130
+ :id="uid"
131
+ class="q-collapse__content"
132
+ :class="{ 'q-collapse__content--open': open }"
133
+ :style="contentStyle"
134
+ @transitionend="onTransitionEnd"
135
+ >
136
+ <div ref="innerEl" class="q-collapse__inner">
137
+ <slot />
138
+ </div>
139
+ </div>
140
+ </div>
141
+ </template>
@@ -0,0 +1,164 @@
1
+ <script setup lang="ts">
2
+ // QConfigProvider — API Quasar : <q-config-provider :theme="{ colors: { primary: '#ff0000' }, componentProps: { QBtn: { radius: 'md' } } }">
3
+ // Fournit les couleurs du thème à tout le sous-arbre via des variables CSS locales
4
+ // (foreground calculé automatiquement), des props par défaut par composant,
5
+ // et rend automatiquement la pile de dialogues programmatiques ($q.dialog)
6
+ // via QDialogProvider — rien à monter de plus.
7
+ import { computed, inject, onBeforeUnmount, onMounted, provide, ref, watch } from "vue"
8
+ import type { StyleValue } from "vue"
9
+ import QDialogProvider from "./QDialogProvider.vue"
10
+ import QNotifyProvider from "./QNotifyProvider.vue"
11
+ import QLoadingProvider from "./QLoadingProvider.vue"
12
+ import QBottomSheetProvider from "./QBottomSheetProvider.vue"
13
+ import QImagePreviewProvider from "./QImagePreviewProvider.vue"
14
+ import { qConfigKey, qProvidersKey } from "../lib/config"
15
+ import type { QConfigContext, QTheme, ThemeMode } from "../lib/config"
16
+ import { isRadiusScale, RADIUS_VALUES } from "../lib/useComponentProps"
17
+
18
+ interface Props {
19
+ /**
20
+ * Thème : objet { mode, colors, componentProps } ou raccourci
21
+ * "light" | "dark" | "system" (mode seul).
22
+ * Ex. : <q-config-provider theme="dark" /> ou
23
+ * :theme="{ mode: 'dark', colors: { primary: '#ff0000' }, componentProps: { QBtn: { radius: 'md' } } }"
24
+ */
25
+ theme?: QTheme | ThemeMode
26
+ /** Rend un div conteneur ; sinon fournit le thème sans élément DOM */
27
+ render?: boolean
28
+ /** Classe(s) additionnelle(s) sur le conteneur */
29
+ class?: string
30
+ /** Styles CSS additionnels fusionnés au style du conteneur */
31
+ style?: StyleValue
32
+ }
33
+
34
+ const props = withDefaults(defineProps<Props>(), {
35
+ theme: () => ({}),
36
+ render: true,
37
+ class: "",
38
+ style: undefined,
39
+ })
40
+
41
+ // Imbrication : le thème du parent est fusionné (le plus proche gagne)
42
+ const parent = inject(qConfigKey, null)
43
+
44
+ /** Normalise la prop theme : string (mode) ou objet */
45
+ const normalizeTheme = (t: QTheme | ThemeMode | undefined): QTheme =>
46
+ typeof t === "string" ? { mode: t } : (t ?? {})
47
+
48
+ /** Fusionne deux maps de componentProps : par composant, le plus proche gagne par prop. */
49
+ const mergeComponentProps = (
50
+ a?: Record<string, Record<string, unknown>>,
51
+ b?: Record<string, Record<string, unknown>>,
52
+ ): Record<string, Record<string, unknown>> => {
53
+ const base = a ?? {}
54
+ const self = b ?? {}
55
+ const keys = new Set([...Object.keys(base), ...Object.keys(self)])
56
+ const out: Record<string, Record<string, unknown>> = {}
57
+ for (const key of keys) {
58
+ out[key] = { ...base[key], ...self[key] }
59
+ }
60
+ return out
61
+ }
62
+
63
+ const mergedTheme = computed<QTheme>(() => {
64
+ const self = normalizeTheme(props.theme)
65
+ const parentTheme = parent?.theme.value
66
+ return {
67
+ mode: self.mode ?? parentTheme?.mode ?? "system",
68
+ colors: { ...parentTheme?.colors, ...self.colors },
69
+ componentProps: mergeComponentProps(parentTheme?.componentProps, self.componentProps),
70
+ }
71
+ })
72
+
73
+ // — Mode clair/sombre —
74
+ const systemDark = ref(false)
75
+ let mql: MediaQueryList | null = null
76
+ const updateSystem = () => {
77
+ systemDark.value = mql?.matches ?? false
78
+ }
79
+
80
+ onMounted(() => {
81
+ if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
82
+ mql = window.matchMedia("(prefers-color-scheme: dark)")
83
+ updateSystem()
84
+ mql.addEventListener("change", updateSystem)
85
+ }
86
+ })
87
+ onBeforeUnmount(() => {
88
+ mql?.removeEventListener("change", updateSystem)
89
+ })
90
+
91
+ /** Mode effectif : light | dark | system résolu */
92
+ const isDark = computed<boolean>(() => {
93
+ const mode = mergedTheme.value.mode
94
+ if (mode === "dark") return true
95
+ if (mode === "light") return false
96
+ return systemDark.value
97
+ })
98
+
99
+ // Applique .dark sur le conteneur ET sur <html> (les overlays téléportés au body
100
+ // — dialog, sheets — doivent aussi passer en sombre)
101
+ watch(
102
+ isDark,
103
+ (dark) => {
104
+ if (typeof document !== "undefined") {
105
+ document.documentElement.classList.toggle("dark", dark)
106
+ }
107
+ },
108
+ { immediate: true },
109
+ )
110
+
111
+ provide<QConfigContext>(qConfigKey, { theme: mergedTheme, isDark })
112
+
113
+ // Providers intégrés ($q.dialog + $q.notify) : rendus UNE fois par le
114
+ // QConfigProvider le plus externe (les imbriqués ne re-rendent pas → pas de doublons)
115
+ const hasProviders = inject(qProvidersKey, false)
116
+ provide(qProvidersKey, true)
117
+ const isProvidersRoot = computed(() => !hasProviders)
118
+
119
+ /** Calcule un foreground lisible (blanc ou sombre) pour un fond donné. */
120
+ const textColorFor = (bg: string): string => {
121
+ let hex = bg.trim().replace(/^#/, "")
122
+ if (hex.length === 3) hex = hex.split("").map((c) => c + c).join("")
123
+ if (!/^[0-9a-f]{6}$/i.test(hex)) return "#ffffff"
124
+ const r = parseInt(hex.slice(0, 2), 16)
125
+ const g = parseInt(hex.slice(2, 4), 16)
126
+ const b = parseInt(hex.slice(4, 6), 16)
127
+ const luminance = 0.299 * r + 0.587 * g + 0.114 * b
128
+ return luminance > 150 ? "#1d1d1d" : "#ffffff"
129
+ }
130
+
131
+ const themeStyle = computed<Record<string, string>>(() => {
132
+ const style: Record<string, string> = {
133
+ "color-scheme": isDark.value ? "dark" : "light",
134
+ }
135
+ for (const [token, value] of Object.entries(mergedTheme.value.colors ?? {})) {
136
+ if (!value) continue
137
+ style[`--${token}`] = value
138
+ style[`--${token}-foreground`] = textColorFor(value)
139
+ }
140
+ // Arrondi global : composantProps.default.radius → --q-radius hérité par TOUS
141
+ // les composants dont le CSS utilise var(--q-radius). Une prop/override
142
+ // spécifique (useRadius) pose son propre --q-radius inline et prime.
143
+ const global = mergedTheme.value.componentProps?.default?.radius
144
+ if (isRadiusScale(global)) style["--q-radius"] = RADIUS_VALUES[global]
145
+ return style
146
+ })
147
+ </script>
148
+
149
+ <template>
150
+ <q-dialog-provider v-if="isProvidersRoot">
151
+ <q-bottom-sheet-provider />
152
+ <q-notify-provider />
153
+ <q-loading-provider />
154
+ <q-image-preview-provider />
155
+ <div v-if="render" class="q-config-provider" :class="[props.class, { dark: isDark }]" :style="[themeStyle, props.style]">
156
+ <slot />
157
+ </div>
158
+ <slot v-else />
159
+ </q-dialog-provider>
160
+ <div v-else-if="render" class="q-config-provider" :class="[props.class, { dark: isDark }]" :style="[themeStyle, props.style]">
161
+ <slot />
162
+ </div>
163
+ <slot v-else />
164
+ </template>
@@ -0,0 +1,201 @@
1
+ <script setup lang="ts">
2
+ // QContainer — conteneur centré à largeur max : <q-container max-width="1200px" fluid>
3
+ // background-effect : "grid" (grille de carrés) | "aurora" (halos colorés animés).
4
+ import { computed, onBeforeUnmount, onMounted, ref } from "vue"
5
+ import { cn } from "../lib/utils"
6
+
7
+ interface Props {
8
+ /** Largeur max du contenu (défaut 1200px) */
9
+ maxWidth?: string | number
10
+ /** Supprime la largeur max (pleine largeur, padding conservé) */
11
+ fluid?: boolean
12
+ /** Padding horizontal (défaut 16px) */
13
+ padding?: string
14
+ /** Effet d'arrière-plan : "grid" | "aurora" | "star" | "falling-stars" | "flickering-grid" | "interactive-grid" | "wave" (vagues qui glissent) */
15
+ backgroundEffect?:
16
+ | "grid"
17
+ | "aurora"
18
+ | "star"
19
+ | "falling-stars"
20
+ | "flickering-grid"
21
+ | "interactive-grid"
22
+ | "wave"
23
+ /** Couleur des lignes de la grille (background-effect="grid") — tout format CSS */
24
+ gridColor?: string
25
+ /** Espacement entre les lignes de la grille (défaut 32px) */
26
+ gridSpacing?: string
27
+ /** Couleur des étoiles (background-effect="star") — tout format CSS */
28
+ starColor?: string
29
+ /** Espacement entre les étoiles (défaut 24px) */
30
+ starSpacing?: string
31
+ /** Taille des points d'étoile (défaut 1px) */
32
+ starDotSize?: string
33
+ /** Couleur du premier halo (background-effect="aurora", défaut cyan) */
34
+ auroraColor?: string
35
+ /** Couleur du second halo (background-effect="aurora", défaut violet) */
36
+ auroraColor2?: string
37
+ /** Couleur de la première vague (background-effect="wave", défaut bleu) */
38
+ waveColor?: string
39
+ /** Couleur de la seconde vague (background-effect="wave", défaut violet) */
40
+ waveColor2?: string
41
+ class?: string
42
+ }
43
+
44
+ const props = withDefaults(defineProps<Props>(), {
45
+ maxWidth: "1200px",
46
+ fluid: false,
47
+ padding: "16px",
48
+ })
49
+
50
+ const containerStyle = computed<Record<string, string>>(() => {
51
+ const style: Record<string, string> = {
52
+ "--q-container-max": typeof props.maxWidth === "number" ? `${props.maxWidth}px` : props.maxWidth,
53
+ "--q-container-pad": props.padding,
54
+ }
55
+ if (props.gridColor) style["--q-grid-color"] = props.gridColor
56
+ if (props.gridSpacing) style["--q-grid-spacing"] = props.gridSpacing
57
+ if (props.starColor) style["--q-star-color"] = props.starColor
58
+ if (props.starSpacing) style["--q-star-spacing"] = props.starSpacing
59
+ if (props.starDotSize) style["--q-star-dot-size"] = props.starDotSize
60
+ if (props.auroraColor) style["--q-aurora-color"] = props.auroraColor
61
+ if (props.auroraColor2) style["--q-aurora-color-2"] = props.auroraColor2
62
+ if (props.waveColor) style["--q-wave-color"] = props.waveColor
63
+ if (props.waveColor2) style["--q-wave-color-2"] = props.waveColor2
64
+ return style
65
+ })
66
+
67
+ const containerClasses = computed(() =>
68
+ cn(
69
+ "q-container",
70
+ props.fluid && "q-container--fluid",
71
+ props.backgroundEffect && "q-container--bg",
72
+ props.class,
73
+ ),
74
+ )
75
+
76
+ // — Étoiles filantes : positions + minutage (durées/délais en s) —
77
+ interface FallingStar {
78
+ left: string
79
+ delay: number
80
+ duration: number
81
+ }
82
+
83
+ const FALLING_STARS: FallingStar[] = [
84
+ { left: "8%", delay: 0, duration: 9 },
85
+ { left: "22%", delay: 2.5, duration: 11 },
86
+ { left: "35%", delay: 5, duration: 8.5 },
87
+ { left: "48%", delay: 1.5, duration: 10 },
88
+ { left: "58%", delay: 4, duration: 9.5 },
89
+ { left: "70%", delay: 6.5, duration: 8 },
90
+ { left: "82%", delay: 3, duration: 10.5 },
91
+ { left: "92%", delay: 7, duration: 9 },
92
+ { left: "15%", delay: 8, duration: 11.5 },
93
+ { left: "63%", delay: 9, duration: 8.5 },
94
+ ]
95
+
96
+ const starStyle = (s: FallingStar): Record<string, string> => ({
97
+ left: s.left,
98
+ "--q-star-delay": `${s.delay}s`,
99
+ "--q-star-duration": `${s.duration}s`,
100
+ })
101
+
102
+ // — Grille qui grésille : cellules qui clignotent à des positions variées —
103
+ interface FlickerCell {
104
+ left: string
105
+ top: string
106
+ delay: number
107
+ duration: number
108
+ }
109
+
110
+ const FLICKER_CELLS: FlickerCell[] = [
111
+ { left: "6%", top: "18%", delay: 0, duration: 2.4 },
112
+ { left: "14%", top: "62%", delay: 0.8, duration: 1.9 },
113
+ { left: "23%", top: "34%", delay: 1.6, duration: 2.8 },
114
+ { left: "31%", top: "78%", delay: 0.3, duration: 2.1 },
115
+ { left: "42%", top: "12%", delay: 2.2, duration: 1.7 },
116
+ { left: "50%", top: "48%", delay: 1.1, duration: 2.6 },
117
+ { left: "58%", top: "84%", delay: 0.5, duration: 2 },
118
+ { left: "66%", top: "28%", delay: 2.7, duration: 1.8 },
119
+ { left: "75%", top: "58%", delay: 1.4, duration: 2.3 },
120
+ { left: "83%", top: "20%", delay: 0.9, duration: 2.5 },
121
+ { left: "90%", top: "70%", delay: 2, duration: 1.9 },
122
+ { left: "36%", top: "92%", delay: 1.8, duration: 2.2 },
123
+ ]
124
+
125
+ const flickerStyle = (c: FlickerCell): Record<string, string> => ({
126
+ left: c.left,
127
+ top: c.top,
128
+ "--q-flicker-delay": `${c.delay}s`,
129
+ "--q-flicker-duration": `${c.duration}s`,
130
+ })
131
+
132
+ // — Grille interactive : le glow suit la souris (vars --q-mouse-x/y sur le root) —
133
+ const rootEl = ref<HTMLElement | null>(null)
134
+
135
+ const onMouseMove = (e: MouseEvent) => {
136
+ if (props.backgroundEffect !== "interactive-grid" || !rootEl.value) return
137
+ const rect = rootEl.value.getBoundingClientRect()
138
+ rootEl.value.style.setProperty("--q-mouse-x", `${e.clientX - rect.left}px`)
139
+ rootEl.value.style.setProperty("--q-mouse-y", `${e.clientY - rect.top}px`)
140
+ }
141
+
142
+ onMounted(() => rootEl.value?.addEventListener("mousemove", onMouseMove))
143
+ onBeforeUnmount(() => rootEl.value?.removeEventListener("mousemove", onMouseMove))
144
+ </script>
145
+
146
+ <template>
147
+ <div ref="rootEl" class="q-container" :class="containerClasses" :style="containerStyle">
148
+ <div
149
+ v-if="backgroundEffect"
150
+ class="q-container__bg"
151
+ :class="`q-container__bg--${backgroundEffect}`"
152
+ aria-hidden="true"
153
+ >
154
+ <template v-if="backgroundEffect === 'falling-stars'">
155
+ <span
156
+ v-for="(s, i) in FALLING_STARS"
157
+ :key="i"
158
+ class="q-container__star"
159
+ :style="starStyle(s)"
160
+ />
161
+ </template>
162
+ <template v-else-if="backgroundEffect === 'flickering-grid'">
163
+ <span
164
+ v-for="(c, i) in FLICKER_CELLS"
165
+ :key="i"
166
+ class="q-container__flicker"
167
+ :style="flickerStyle(c)"
168
+ />
169
+ </template>
170
+ <span
171
+ v-else-if="backgroundEffect === 'interactive-grid'"
172
+ class="q-container__glow"
173
+ />
174
+ <template v-else-if="backgroundEffect === 'wave'">
175
+ <svg
176
+ class="q-container__wave q-container__wave--1"
177
+ viewBox="0 0 960 128"
178
+ preserveAspectRatio="none"
179
+ aria-hidden="true"
180
+ >
181
+ <path
182
+ d="M0,64 C80,100 160,28 240,64 C320,100 400,28 480,64 C560,100 640,28 720,64 C800,100 880,28 960,64"
183
+ fill="var(--q-wave-color)"
184
+ />
185
+ </svg>
186
+ <svg
187
+ class="q-container__wave q-container__wave--2"
188
+ viewBox="0 0 960 128"
189
+ preserveAspectRatio="none"
190
+ aria-hidden="true"
191
+ >
192
+ <path
193
+ d="M0,64 C80,100 160,28 240,64 C320,100 400,28 480,64 C560,100 640,28 720,64 C800,100 880,28 960,64"
194
+ fill="var(--q-wave-color-2)"
195
+ />
196
+ </svg>
197
+ </template>
198
+ </div>
199
+ <slot />
200
+ </div>
201
+ </template>
@@ -0,0 +1,164 @@
1
+ <script setup lang="ts">
2
+ // QCountDown — compte à rebours : <q-count-down :time="90" format="mm:ss" @end="…" />
3
+ // ou vers une date cible : <q-count-down :to="new Date('2026-12-31')" />
4
+ // Format : DD | HH | mm | ss | SSS (défaut "HH:mm:ss"). Slot : données brutes + progress.
5
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"
6
+
7
+ interface Props {
8
+ /** Durée initiale en SECONDES (ignoré si `to` est fourni) */
9
+ time?: number
10
+ /** Date cible : Date | timestamp | chaîne ISO — compte jusqu'à cette date */
11
+ to?: string | number | Date
12
+ /** Démarre automatiquement (défaut true) */
13
+ autoStart?: boolean
14
+ /** Pause / reprise (contrôle externe) */
15
+ pause?: boolean
16
+ /** Format d'affichage : DD, HH, mm, ss, SSS (défaut "HH:mm:ss") */
17
+ format?: string
18
+ /** Élément rendu (défaut span) */
19
+ tag?: string
20
+ }
21
+
22
+ const props = withDefaults(defineProps<Props>(), {
23
+ time: 0,
24
+ autoStart: true,
25
+ pause: false,
26
+ format: "HH:mm:ss",
27
+ tag: "span",
28
+ })
29
+
30
+ const emit = defineEmits<{ end: [] }>()
31
+
32
+ const totalMs = ref(0)
33
+ const remainingMs = ref(0)
34
+ const endAt = ref(0)
35
+ const running = ref(false)
36
+ let timer: ReturnType<typeof setInterval> | null = null
37
+
38
+ const computeTotal = () => {
39
+ if (props.to !== undefined) {
40
+ const t =
41
+ props.to instanceof Date
42
+ ? props.to.getTime()
43
+ : typeof props.to === "number"
44
+ ? props.to
45
+ : new Date(props.to).getTime()
46
+ totalMs.value = Math.max(0, t - Date.now())
47
+ } else {
48
+ totalMs.value = Math.max(0, props.time * 1000)
49
+ }
50
+ }
51
+
52
+ const stopTimer = () => {
53
+ if (timer) clearInterval(timer)
54
+ timer = null
55
+ }
56
+
57
+ const tick = () => {
58
+ const rem = Math.max(0, endAt.value - Date.now())
59
+ remainingMs.value = rem
60
+ if (rem <= 0) {
61
+ running.value = false
62
+ stopTimer()
63
+ emit("end")
64
+ }
65
+ }
66
+
67
+ /** Démarre (ou reprend) le compte */
68
+ const start = () => {
69
+ if (props.pause || remainingMs.value <= 0 || running.value) return
70
+ endAt.value = Date.now() + remainingMs.value
71
+ stopTimer()
72
+ timer = setInterval(tick, 250)
73
+ running.value = true
74
+ }
75
+
76
+ /** Met en pause */
77
+ const pauseTimer = () => {
78
+ running.value = false
79
+ stopTimer()
80
+ }
81
+
82
+ /** Réinitialise à la durée initiale (et relance si autoStart) */
83
+ const reset = () => {
84
+ computeTotal()
85
+ remainingMs.value = totalMs.value
86
+ if (props.autoStart && !props.pause) start()
87
+ else running.value = false
88
+ }
89
+
90
+ /** Temps restant (ms) */
91
+ const getRemaining = () => remainingMs.value
92
+ /** Progression écoulée 0 → 1 */
93
+ const getProgress = () => (totalMs.value > 0 ? 1 - remainingMs.value / totalMs.value : 0)
94
+
95
+ defineExpose({ start, pause: pauseTimer, reset, getRemaining, getProgress })
96
+
97
+ onMounted(() => {
98
+ computeTotal()
99
+ remainingMs.value = totalMs.value
100
+ if (props.autoStart && !props.pause) start()
101
+ })
102
+ onBeforeUnmount(stopTimer)
103
+
104
+ watch(
105
+ () => props.pause,
106
+ (v) => {
107
+ if (v) pauseTimer()
108
+ else if (remainingMs.value > 0) start()
109
+ },
110
+ )
111
+ watch(
112
+ () => [props.time, props.to],
113
+ () => reset(),
114
+ )
115
+
116
+ // — Unitées + format —
117
+ const units = computed(() => {
118
+ const rem = remainingMs.value
119
+ const days = Math.floor(rem / 86400000)
120
+ const hours = Math.floor((rem % 86400000) / 3600000)
121
+ const minutes = Math.floor((rem % 3600000) / 60000)
122
+ const seconds = Math.floor((rem % 60000) / 1000)
123
+ const ms = rem % 1000
124
+ return { days, hours, minutes, seconds, ms, remaining: rem }
125
+ })
126
+
127
+ const formatted = computed(() => {
128
+ const { days, hours, minutes, seconds, ms } = units.value
129
+ const totalHours = Math.floor(remainingMs.value / 3600000)
130
+ const h = props.format.includes("D") ? hours : totalHours
131
+ const pad = (n: number, len = 2) => String(n).padStart(len, "0")
132
+ return props.format
133
+ .replace(/SSS/g, pad(ms, 3))
134
+ .replace(/SS/g, pad(Math.floor(ms / 10), 2))
135
+ .replace(/S/g, String(Math.floor(ms / 100)))
136
+ .replace(/DD/g, pad(days))
137
+ .replace(/D/g, String(days))
138
+ .replace(/HH/g, pad(h))
139
+ .replace(/H/g, String(h))
140
+ .replace(/mm/g, pad(minutes))
141
+ .replace(/m/g, String(minutes))
142
+ .replace(/ss/g, pad(seconds))
143
+ .replace(/s/g, String(seconds))
144
+ })
145
+
146
+ const progress = computed(() => (totalMs.value > 0 ? 1 - remainingMs.value / totalMs.value : 0))
147
+ </script>
148
+
149
+ <template>
150
+ <component
151
+ :is="tag"
152
+ class="q-count-down"
153
+ :class="{ 'q-count-down--done': remainingMs <= 0 && totalMs > 0 }"
154
+ >
155
+ <slot
156
+ v-bind="{ ...units, progress, running, formatted }"
157
+ :formatted="formatted"
158
+ :progress="progress"
159
+ :running="running"
160
+ >
161
+ {{ formatted }}
162
+ </slot>
163
+ </component>
164
+ </template>