@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,217 @@
1
+ <script setup lang="ts">
2
+ // QSwipeCell — cellule que l'on glisse horizontalement pour révéler des actions
3
+ // (API Vant SwipeCell) : <q-swipe-cell :right-width="160"><template #right>…</template>…</q-swipe-cell>.
4
+ // Drag vers la gauche → panneau droit, vers la droite → panneau gauche (snap à la moitié).
5
+ // Props : left-width, right-width, disabled, name, before-close.
6
+ // Events : open / close / click ({ name, position }). Méthodes : open, close.
7
+ import { onBeforeUnmount, ref, watch } from "vue"
8
+
9
+ type SwipePosition = "left" | "right"
10
+
11
+ interface Props {
12
+ /** Largeur du panneau d'actions de gauche, en px (0 = aucun) */
13
+ leftWidth?: number
14
+ /** Largeur du panneau d'actions de droite, en px (0 = aucun) */
15
+ rightWidth?: number
16
+ /** Désactive le glissement */
17
+ disabled?: boolean
18
+ /** Bloque le glissement quand les actions sont affichées (on ferme par clic) */
19
+ lockOnOpen?: boolean
20
+ /** Identifiant (retourné dans les events, pour la coordination) */
21
+ name?: string | number
22
+ /** Hook avant fermeture : retourner false (ou Promise false) garde la cellule ouverte */
23
+ beforeClose?: (args: { name?: string | number; position: SwipePosition | "" }) => boolean | Promise<boolean>
24
+ }
25
+
26
+ const props = withDefaults(defineProps<Props>(), {
27
+ leftWidth: 0,
28
+ rightWidth: 0,
29
+ disabled: false,
30
+ lockOnOpen: false,
31
+ name: "",
32
+ beforeClose: undefined,
33
+ })
34
+
35
+ const emit = defineEmits<{
36
+ open: [payload: { name?: string | number; position: SwipePosition }]
37
+ close: [payload: { name?: string | number; position: SwipePosition }]
38
+ click: [payload: { name?: string | number; position: SwipePosition | "" }]
39
+ }>()
40
+
41
+ const rootEl = ref<HTMLElement | null>(null)
42
+ const wrapperEl = ref<HTMLElement | null>(null)
43
+
44
+ const offset = ref(0)
45
+ const opened = ref<SwipePosition | "">("")
46
+ let dragging = false
47
+ let moved = false
48
+ let startX = 0
49
+ let startOffset = 0
50
+
51
+ const maxLeft = () => props.leftWidth || 0
52
+ const maxRight = () => props.rightWidth || 0
53
+
54
+ /** Applique la translation au wrapper (avec transition) */
55
+ const applyOffset = (v: number, transition = true) => {
56
+ offset.value = v
57
+ const el = wrapperEl.value
58
+ if (!el) return
59
+ if (transition) el.style.transition = "transform 0.3s ease"
60
+ el.style.transform = `translateX(${v}px)`
61
+ if (transition) {
62
+ setTimeout(() => el.style.removeProperty("transition"), 320)
63
+ }
64
+ const position: SwipePosition | "" = v < 0 ? "right" : v > 0 ? "left" : ""
65
+ if (position !== opened.value) {
66
+ const from = opened.value
67
+ opened.value = position
68
+ if (from) emit("close", { name: props.name, position: from })
69
+ if (position) emit("open", { name: props.name, position })
70
+ }
71
+ }
72
+
73
+ const onPointerDown = (e: PointerEvent) => {
74
+ if (props.disabled) return
75
+ // lock-on-open : quand les actions sont affichées, le glissement est bloqué
76
+ if (props.lockOnOpen && opened.value) return
77
+ dragging = true
78
+ moved = false
79
+ startX = e.clientX
80
+ startOffset = offset.value
81
+ ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
82
+ if (wrapperEl.value) wrapperEl.value.style.removeProperty("transition")
83
+ }
84
+
85
+ const onPointerMove = (e: PointerEvent) => {
86
+ if (!dragging) return
87
+ const dx = e.clientX - startX
88
+ if (!moved && Math.abs(dx) > 4) moved = true
89
+ if (!moved) return
90
+ const next = Math.min(maxLeft(), Math.max(-maxRight(), startOffset + dx))
91
+ offset.value = next // <— indispensable : le snap (pointerup) se base dessus
92
+ if (wrapperEl.value) wrapperEl.value.style.transform = `translateX(${next}px)`
93
+ }
94
+
95
+ const onPointerUp = () => {
96
+ if (!dragging) return
97
+ dragging = false
98
+ if (moved) {
99
+ // snap : ouvre le côté si on a dépassé la moitié, sinon revient
100
+ const target =
101
+ offset.value <= -maxRight() / 2
102
+ ? -maxRight()
103
+ : offset.value >= maxLeft() / 2
104
+ ? maxLeft()
105
+ : 0
106
+ applyOffset(target)
107
+ }
108
+ }
109
+
110
+ const close = async () => {
111
+ if (offset.value === 0) return
112
+ if (props.beforeClose) {
113
+ const ok = await props.beforeClose({ name: props.name, position: opened.value })
114
+ if (ok === false) return
115
+ }
116
+ applyOffset(0)
117
+ }
118
+
119
+ /** Fermeture forcée, sans passer par before-close (clic sur la cellule) */
120
+ const forceClose = () => applyOffset(0)
121
+
122
+ const open = (position: SwipePosition) => {
123
+ const w = position === "left" ? maxLeft() : maxRight()
124
+ if (!w) return
125
+ applyOffset(position === "left" ? w : -w)
126
+ }
127
+
128
+ const onClickCell = () => {
129
+ if (opened.value) {
130
+ // Le clic sur la cellule referme TOUJOURS (before-close ne s'applique qu'aux actions)
131
+ forceClose()
132
+ } else {
133
+ emit("click", { name: props.name, position: "" })
134
+ }
135
+ }
136
+
137
+ const onClickAction = (position: SwipePosition) => {
138
+ emit("click", { name: props.name, position })
139
+ close()
140
+ }
141
+
142
+ defineExpose({ open, close })
143
+
144
+ // — Clic extérieur : referme la cellule ouverte (clic en dehors des actions) —
145
+ const onDocPointerDown = (e: PointerEvent) => {
146
+ if (!opened.value) return
147
+ if (rootEl.value && !rootEl.value.contains(e.target as Node)) {
148
+ forceClose()
149
+ }
150
+ }
151
+
152
+ watch(opened, (v) => {
153
+ if (v) document.addEventListener("pointerdown", onDocPointerDown)
154
+ else document.removeEventListener("pointerdown", onDocPointerDown)
155
+ })
156
+
157
+ onBeforeUnmount(() => {
158
+ document.removeEventListener("pointerdown", onDocPointerDown)
159
+ if (wrapperEl.value) wrapperEl.value.style.removeProperty("transition")
160
+ })
161
+ </script>
162
+
163
+ <template>
164
+ <div ref="rootEl" class="q-swipe-cell">
165
+ <div class="q-swipe-cell__left" :style="{ width: `${leftWidth}px` }" @click="onClickAction('left')">
166
+ <slot name="left" />
167
+ </div>
168
+ <div class="q-swipe-cell__right" :style="{ width: `${rightWidth}px` }" @click="onClickAction('right')">
169
+ <slot name="right" />
170
+ </div>
171
+ <div
172
+ ref="wrapperEl"
173
+ class="q-swipe-cell__wrapper"
174
+ @pointerdown="onPointerDown"
175
+ @pointermove="onPointerMove"
176
+ @pointerup="onPointerUp"
177
+ @pointercancel="onPointerUp"
178
+ @click="onClickCell"
179
+ >
180
+ <slot />
181
+ </div>
182
+ </div>
183
+ </template>
184
+
185
+ <style scoped>
186
+ .q-swipe-cell {
187
+ position: relative;
188
+ overflow: hidden;
189
+ }
190
+
191
+ /* panneaux d'actions : à leur place DANS le conteneur, derrière le wrapper
192
+ (qui les couvre) — le glissement du wrapper les révèle */
193
+ .q-swipe-cell__left,
194
+ .q-swipe-cell__right {
195
+ position: absolute;
196
+ top: 0;
197
+ height: 100%;
198
+ display: flex;
199
+ align-items: stretch;
200
+ z-index: 0;
201
+ }
202
+ .q-swipe-cell__left {
203
+ left: 0;
204
+ }
205
+ .q-swipe-cell__right {
206
+ right: 0;
207
+ }
208
+
209
+ /* contenu de la cellule : glisse horizontalement AU-DESSUS des panneaux */
210
+ .q-swipe-cell__wrapper {
211
+ position: relative;
212
+ z-index: 1;
213
+ transition: transform 0.3s ease;
214
+ touch-action: pan-y; /* scroll vertical OK, glissement horizontal capté */
215
+ cursor: grab;
216
+ }
217
+ </style>
@@ -0,0 +1,102 @@
1
+ <script setup lang="ts">
2
+ // QSwiper — carrousel type Swiper (API swiperjs/vue) :
3
+ // <q-swiper :slides-per-view="2" :space-between="16" loop navigation :autoplay="{ delay: 2000 }" effect="fade">
4
+ // Wrapper autour de swiper/vue : TOUS les paramètres Swiper passent en props/attrs
5
+ // (slides-per-view, space-between, loop, centered-slides, navigation, pagination,
6
+ // autoplay, effect, virtual, thumbs…), les événements via @slide-change, @swiper…
7
+ // Tous les modules courants sont pré-enregistrés — remplaçables via :modules.
8
+ import { Swiper } from "swiper/vue"
9
+ import {
10
+ A11y,
11
+ Autoplay,
12
+ Controller,
13
+ EffectCards,
14
+ EffectCoverflow,
15
+ EffectCreative,
16
+ EffectCube,
17
+ EffectFade,
18
+ EffectFlip,
19
+ FreeMode,
20
+ Grid,
21
+ HashNavigation,
22
+ History,
23
+ Keyboard,
24
+ Manipulation,
25
+ Mousewheel,
26
+ Navigation,
27
+ Pagination,
28
+ Parallax,
29
+ Scrollbar,
30
+ Thumbs,
31
+ Virtual,
32
+ Zoom,
33
+ } from "swiper/modules"
34
+
35
+ // Styles Swiper (core + modules)
36
+ import "swiper/css"
37
+ import "swiper/css/a11y"
38
+ import "swiper/css/autoplay"
39
+ import "swiper/css/controller"
40
+ import "swiper/css/effect-cards"
41
+ import "swiper/css/effect-coverflow"
42
+ import "swiper/css/effect-creative"
43
+ import "swiper/css/effect-cube"
44
+ import "swiper/css/effect-fade"
45
+ import "swiper/css/effect-flip"
46
+ import "swiper/css/free-mode"
47
+ import "swiper/css/grid"
48
+ import "swiper/css/hash-navigation"
49
+ import "swiper/css/history"
50
+ import "swiper/css/keyboard"
51
+ import "swiper/css/manipulation"
52
+ import "swiper/css/mousewheel"
53
+ import "swiper/css/navigation"
54
+ import "swiper/css/pagination"
55
+ import "swiper/css/parallax"
56
+ import "swiper/css/scrollbar"
57
+ import "swiper/css/thumbs"
58
+ import "swiper/css/virtual"
59
+ import "swiper/css/zoom"
60
+
61
+ interface Props {
62
+ /** Modules Swiper — défaut : tous les modules courants (navigation, pagination,
63
+ * autoplay, effets, virtual, thumbs…). Passez un tableau custom pour réduire le bundle. */
64
+ modules?: unknown[]
65
+ }
66
+
67
+ const props = withDefaults(defineProps<Props>(), {
68
+ modules: () => [
69
+ A11y,
70
+ Autoplay,
71
+ Controller,
72
+ EffectCards,
73
+ EffectCoverflow,
74
+ EffectCreative,
75
+ EffectCube,
76
+ EffectFade,
77
+ EffectFlip,
78
+ FreeMode,
79
+ Grid,
80
+ HashNavigation,
81
+ History,
82
+ Keyboard,
83
+ Manipulation,
84
+ Mousewheel,
85
+ Navigation,
86
+ Pagination,
87
+ Parallax,
88
+ Scrollbar,
89
+ Thumbs,
90
+ Virtual,
91
+ Zoom,
92
+ ],
93
+ })
94
+ </script>
95
+
96
+ <template>
97
+ <div class="q-swiper">
98
+ <Swiper v-bind="$attrs" :modules="modules">
99
+ <slot />
100
+ </Swiper>
101
+ </div>
102
+ </template>
@@ -0,0 +1,19 @@
1
+ <script setup lang="ts">
2
+ // QSwiperSlide — slide de QSwiper (wrapper de SwiperSlide) :
3
+ // <q-swiper-slide v-for="(s, i) in slides" :key="i" :virtual-index="i">
4
+ // Reçoit les props de SwiperSlide (tag, zoom, virtualIndex) + swiperRef/swiperSlideIndex
5
+ // injectés automatiquement par le Swiper parent. Le slot expose isActive/isVisible/isPrev/isNext.
6
+ import { SwiperSlide } from "swiper/vue"
7
+
8
+ // swiper/vue identifie les slides par type.name === "SwiperSlide" — le wrapper
9
+ // doit conserver ce nom pour que le parent les récupère.
10
+ defineOptions({ name: "SwiperSlide" })
11
+ </script>
12
+
13
+ <template>
14
+ <SwiperSlide v-bind="$attrs">
15
+ <template v-for="(_, name) in $slots" :key="name" #[name]="slotProps">
16
+ <slot :name="name" v-bind="slotProps" />
17
+ </template>
18
+ </SwiperSlide>
19
+ </template>
@@ -0,0 +1,171 @@
1
+ <script setup lang="ts">
2
+ // QSyntax — bloc de code avec coloration syntaxique Shiki (https://shiki.style).
3
+ // <q-syntax code="const a = 1" lang="ts" theme="github-dark" filename="app.ts" copy />
4
+ // code peut venir de la prop `code` ou du slot par défaut.
5
+ // Rendu côté client : le code brut s'affiche en fallback, puis est surligné.
6
+ import { computed, onBeforeUnmount, onMounted, ref, useSlots, watch } from "vue"
7
+ import { createHighlighter, type Highlighter } from "shiki"
8
+ import { createJavaScriptRegexEngine } from "shiki/engine/javascript"
9
+ import { Icon } from "@iconify/vue"
10
+ import { icons } from "../lib/icons"
11
+ import { radiusStyle, useRadius } from "../lib/useComponentProps"
12
+ import type { RadiusProp } from "../lib/useComponentProps"
13
+
14
+ const DEFAULT_THEME = "github-dark-default"
15
+
16
+ // Langages chargés par défaut (grammaires chargées à la demande par Shiki).
17
+ // Les autres langages sont chargés via loadLanguage ; repli sur "text" si inconnu.
18
+ const DEFAULT_LANGS = [
19
+ "vue", "ts", "js", "tsx", "jsx", "html", "css", "scss", "json", "jsonc",
20
+ "bash", "markdown", "yaml", "python", "go", "rust", "sql",
21
+ ]
22
+
23
+ interface Props {
24
+ /** Code à afficher (sinon contenu du slot par défaut) */
25
+ code?: string
26
+ /** Langage Shiki : vue, ts, js, html, css, json, bash, markdown, yaml… */
27
+ lang?: string
28
+ /** Thème Shiki (défaut : github-dark-default) */
29
+ theme?: string
30
+ /** Nom de fichier affiché dans la barre de titre */
31
+ filename?: string
32
+ /** Bouton copier */
33
+ copy?: boolean
34
+ /** Coins arrondis : true = md, ou échelle xs|sm|md|lg (none = carré) — défaut : composantProps */
35
+ radius?: RadiusProp
36
+ }
37
+
38
+ const props = withDefaults(defineProps<Props>(), {
39
+ lang: "text",
40
+ theme: DEFAULT_THEME,
41
+ filename: "",
42
+ copy: false,
43
+ })
44
+
45
+ // radius : prop explicite > composantProps.QSyntax.radius > composantProps.default.radius
46
+ const effectiveRadius = useRadius("QSyntax", () => props.radius)
47
+ const roundedStyle = computed(() => radiusStyle(effectiveRadius.value))
48
+
49
+ const slots = useSlots()
50
+
51
+ // Texte du slot par défaut (si pas de prop code)
52
+ const slotCode = computed(() => {
53
+ const extract = (children: unknown): string => {
54
+ if (typeof children === "string") return children
55
+ if (Array.isArray(children)) return children.map(extract).join("")
56
+ if (children && typeof children === "object" && "children" in children) {
57
+ return extract((children as { children: unknown }).children)
58
+ }
59
+ return ""
60
+ }
61
+ return (slots.default?.() ?? []).map((v) => extract(v.children)).join("")
62
+ })
63
+
64
+ const source = computed(() => props.code ?? slotCode.value)
65
+
66
+ // Highlighter singleton : une seule instance pour toute l'application
67
+ let highlighterPromise: Promise<Highlighter> | null = null
68
+ const getHighlighter = () => {
69
+ highlighterPromise ??= createHighlighter({
70
+ themes: [DEFAULT_THEME],
71
+ langs: DEFAULT_LANGS,
72
+ engine: createJavaScriptRegexEngine(),
73
+ })
74
+ return highlighterPromise
75
+ }
76
+
77
+ const html = ref("")
78
+ const highlighted = ref(false)
79
+ const error = ref(false)
80
+
81
+ const highlight = async () => {
82
+ const code = source.value
83
+ if (!code) {
84
+ html.value = ""
85
+ highlighted.value = false
86
+ return
87
+ }
88
+ try {
89
+ const highlighter = await getHighlighter()
90
+
91
+ // Thème custom chargé à la demande
92
+ let theme = props.theme
93
+ if (theme !== DEFAULT_THEME) {
94
+ try {
95
+ await highlighter.loadTheme(theme as never)
96
+ } catch {
97
+ theme = DEFAULT_THEME
98
+ }
99
+ }
100
+
101
+ // Langage custom chargé à la demande ; repli sur "text"
102
+ let lang = props.lang
103
+ if (lang !== "text" && !highlighter.getLoadedLanguages().includes(lang)) {
104
+ try {
105
+ await highlighter.loadLanguage(lang as never)
106
+ } catch {
107
+ lang = "text"
108
+ }
109
+ }
110
+
111
+ try {
112
+ html.value = highlighter.codeToHtml(code, { lang, theme })
113
+ } catch {
114
+ html.value = highlighter.codeToHtml(code, { lang, theme: DEFAULT_THEME })
115
+ }
116
+ highlighted.value = true
117
+ error.value = false
118
+ }
119
+ catch (e) {
120
+ console.error("[QSyntax]", e)
121
+ error.value = true
122
+ }
123
+ }
124
+
125
+ watch(() => [source.value, props.lang, props.theme], () => {
126
+ highlighted.value = false
127
+ highlight()
128
+ })
129
+ onMounted(highlight)
130
+
131
+ // — Copier —
132
+ const copied = ref(false)
133
+ let copyTimer: ReturnType<typeof setTimeout> | undefined
134
+ const onCopy = async () => {
135
+ try {
136
+ await navigator.clipboard.writeText(source.value)
137
+ copied.value = true
138
+ clearTimeout(copyTimer)
139
+ copyTimer = setTimeout(() => (copied.value = false), 1600)
140
+ }
141
+ catch {
142
+ /* presse-papiers indisponible */
143
+ }
144
+ }
145
+ onBeforeUnmount(() => clearTimeout(copyTimer))
146
+ </script>
147
+
148
+ <template>
149
+ <div class="q-syntax" :class="{ 'q-syntax--error': error }" :style="roundedStyle">
150
+ <div v-if="filename || copy" class="q-syntax__bar">
151
+ <span v-if="filename" class="q-syntax__filename">
152
+ <Icon :icon="icons.fileCode" aria-hidden="true" />
153
+ {{ filename }}
154
+ </span>
155
+ <button
156
+ v-if="copy"
157
+ type="button"
158
+ class="q-syntax__copy"
159
+ :class="{ 'q-syntax__copy--done': copied }"
160
+ :aria-label="copied ? 'Copied' : 'Copy code'"
161
+ @click="onCopy"
162
+ >
163
+ <Icon :icon="copied ? icons.check : icons.copy" aria-hidden="true" />
164
+ {{ copied ? "Copied" : "Copy" }}
165
+ </button>
166
+ </div>
167
+
168
+ <div v-if="highlighted" class="q-syntax__body" v-html="html" />
169
+ <pre v-else class="q-syntax__plain"><code>{{ source }}</code></pre>
170
+ </div>
171
+ </template>
@@ -0,0 +1,175 @@
1
+ <script setup lang="ts">
2
+ // QTab — API Quasar (source officielle) :
3
+ // <q-tab name="a" icon="…" label="Accueil" alert="negative" alert-icon="…" no-caps />
4
+ // alert : true = point de notification ; string = couleur du point (token ou hex).
5
+ // name : auto-généré (t_1, t_2…) si absent, comme chez Quasar.
6
+ import { computed, inject, onBeforeUnmount, onMounted, ref } from "vue"
7
+ import { Icon } from "@iconify/vue"
8
+ import { cn } from "../lib/utils"
9
+ import { qTabsKey } from "./QTabs.vue"
10
+
11
+ const TOKEN_COLORS = ["primary", "secondary", "accent", "dark", "positive", "negative", "info", "warning"] as const
12
+
13
+ /** Token → var(--token), sinon valeur directe (hex, rgb(), nom CSS) */
14
+ const colorValue = (c: string) =>
15
+ (TOKEN_COLORS as readonly string[]).includes(c) ? `var(--${c})` : c
16
+
17
+ let tabSeq = 0
18
+
19
+ interface Props {
20
+ /** Identifiant du tab (comparé au v-model du QTabs) */
21
+ name?: string | number
22
+ /** Icône Iconify (ex. : "lucide:home") */
23
+ icon?: string
24
+ /** Texte ou nombre */
25
+ label?: string | number
26
+ /** true = point de notification ; string = couleur du point */
27
+ alert?: boolean | string
28
+ /** Icône Iconify remplaçant le point de notification */
29
+ alertIcon?: string
30
+ /** Nombre de notifications — badge numérique (masqué si 0 ou absent) */
31
+ count?: number
32
+ /** Couleur du badge count — token ou hex (défaut : couleur de alert, sinon negative) */
33
+ countColor?: string
34
+ /** Seuil d'affichage : au-delà, « max+ » (défaut : 99) */
35
+ countMax?: number
36
+ /** Pas de mise en majuscules */
37
+ noCaps?: boolean
38
+ tabindex?: string | number
39
+ /** Désactive le tab */
40
+ disable?: boolean
41
+ /** Classe du contenu interne */
42
+ contentClass?: string
43
+ }
44
+
45
+ const props = withDefaults(defineProps<Props>(), {
46
+ alert: false,
47
+ noCaps: false,
48
+ disable: false,
49
+ countMax: 99,
50
+ })
51
+
52
+ const emit = defineEmits<{ click: [event: MouseEvent]; keydown: [event: KeyboardEvent] }>()
53
+
54
+ const tabs = inject(qTabsKey, null)
55
+
56
+ const resolvedName = props.name ?? `t_${tabSeq++}`
57
+
58
+ const el = ref<HTMLElement | null>(null)
59
+
60
+ onMounted(() => tabs?.register({ name: resolvedName, el }))
61
+ onBeforeUnmount(() => tabs?.unregister(resolvedName))
62
+
63
+ const isActive = computed(() => tabs?.activeName.value === resolvedName)
64
+
65
+ // Roving tabindex (WAI-ARIA) : seul le tab actif est dans le tab order
66
+ const tabIndex = computed(() =>
67
+ props.disable ? -1 : (props.tabindex ?? (isActive.value ? 0 : -1)),
68
+ )
69
+
70
+ const alertColor = computed(() => (typeof props.alert === "string" ? props.alert : undefined))
71
+ const alertStyle = computed<Record<string, string>>((): Record<string, string> =>
72
+ alertColor.value ? { backgroundColor: colorValue(alertColor.value) } : {},
73
+ )
74
+ const alertIconStyle = computed<Record<string, string>>((): Record<string, string> =>
75
+ alertColor.value ? { color: colorValue(alertColor.value) } : {},
76
+ )
77
+
78
+ // Badge numérique (notifications non lues…)
79
+ const hasCount = computed(() => typeof props.count === "number" && props.count > 0)
80
+ const countDisplay = computed(() => {
81
+ if (!hasCount.value) return ""
82
+ return props.count! > props.countMax ? props.countMax + "+" : String(props.count)
83
+ })
84
+ const countColor = computed(() =>
85
+ props.countColor ?? (typeof props.alert === "string" ? props.alert : "negative"),
86
+ )
87
+ const countStyle = computed<Record<string, string>>(() => ({
88
+ backgroundColor: colorValue(countColor.value),
89
+ }))
90
+
91
+ const tabClasses = computed(() =>
92
+ cn(
93
+ "q-tab",
94
+ isActive.value && "q-tab--active",
95
+ isActive.value && tabs?.activeClass.value,
96
+ (props.noCaps || tabs?.noCaps.value) && "q-tab--no-caps",
97
+ props.disable && "q-tab--disabled",
98
+ tabs?.inlineLabel.value && "q-tab--inline-label",
99
+ ),
100
+ )
101
+
102
+ const activate = () => {
103
+ if (!props.disable) tabs?.setActive(resolvedName)
104
+ }
105
+
106
+ const onClick = (e: MouseEvent) => {
107
+ activate()
108
+ emit("click", e)
109
+ }
110
+
111
+ // Clavier (WAI-ARIA) : Entrée/Espace active, flèches/Home/End déplacent le focus
112
+ const onKeydown = (e: KeyboardEvent) => {
113
+ emit("keydown", e)
114
+ if (e.defaultPrevented || props.disable) return
115
+
116
+ if (e.key === "Enter" || e.key === " ") {
117
+ e.preventDefault()
118
+ activate()
119
+ return
120
+ }
121
+
122
+ const root = el.value?.parentElement
123
+ if (!root) return
124
+ const list = Array.from(root.querySelectorAll(".q-tab")) as HTMLElement[]
125
+ const index = list.indexOf(el.value as HTMLElement)
126
+ let target: HTMLElement | undefined
127
+ if (e.key === "ArrowRight") target = list[(index + 1) % list.length]
128
+ else if (e.key === "ArrowLeft") target = list[(index - 1 + list.length) % list.length]
129
+ else if (e.key === "Home") target = list[0]
130
+ else if (e.key === "End") target = list[list.length - 1]
131
+ if (target) {
132
+ e.preventDefault()
133
+ target.focus()
134
+ }
135
+ }
136
+ </script>
137
+
138
+ <template>
139
+ <div
140
+ ref="el"
141
+ role="tab"
142
+ :aria-selected="isActive ? 'true' : 'false'"
143
+ :aria-disabled="disable || undefined"
144
+ :tabindex="tabIndex"
145
+ class="q-tab"
146
+ :class="tabClasses"
147
+ @click="onClick"
148
+ @keydown="onKeydown"
149
+ >
150
+ <div class="q-tab__content" :class="contentClass">
151
+ <Icon :icon="icon" v-if="icon" class="q-tab__icon" aria-hidden="true" />
152
+ <span v-if="label !== undefined" class="q-tab__label">{{ label }}</span>
153
+ <slot v-else />
154
+ <span
155
+ v-if="alert && !alertIcon && !hasCount"
156
+ class="q-tab__alert"
157
+ :style="alertStyle"
158
+ aria-label="Notification"
159
+ />
160
+ <span
161
+ v-if="hasCount && !alertIcon"
162
+ class="q-tab__count"
163
+ :style="countStyle"
164
+ :aria-label="countDisplay + ' notifications'"
165
+ >{{ countDisplay }}</span>
166
+ <Icon
167
+ :icon="alertIcon"
168
+ v-if="alertIcon"
169
+ class="q-tab__alert-icon"
170
+ :style="alertIconStyle"
171
+ aria-hidden="true"
172
+ />
173
+ </div>
174
+ </div>
175
+ </template>