@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,238 @@
1
+ <script setup lang="ts">
2
+ // QInputOtp — saisie de code à usage unique : <q-input-otp v-model="code" length="6" numeric />
3
+ // Une case par caractère, avance automatique, Backspace/Delete, flèches, collage,
4
+ // masquage (password), complétion émise via @complete.
5
+ import { computed, ref, watch } from "vue"
6
+ import { cn } from "../lib/utils"
7
+ import { radiusStyle, useRadius } from "../lib/useComponentProps"
8
+ import type { RadiusProp } from "../lib/useComponentProps"
9
+
10
+ type OtpSize = "sm" | "md" | "lg"
11
+
12
+ interface Props {
13
+ /** Code (v-model) — chaîne, longueur ≤ length */
14
+ modelValue?: string
15
+ /** Nombre de cases (défaut : 6) */
16
+ length?: number
17
+ /** Label affiché au-dessus du champ */
18
+ label?: string
19
+ hint?: string
20
+ error?: boolean
21
+ errorMessage?: string
22
+ /** Variantes de champ (même vocabulaire que q-input) */
23
+ outlined?: boolean
24
+ filled?: boolean
25
+ borderless?: boolean
26
+ /** Hauteur réduite */
27
+ dense?: boolean
28
+ disable?: boolean
29
+ readonly?: boolean
30
+ /** Restreint la saisie aux chiffres (inputmode numeric sur mobile) */
31
+ numeric?: boolean
32
+ /** Masque les caractères (type password) */
33
+ password?: boolean
34
+ /** Taille des cases : sm | md | lg */
35
+ size?: OtpSize
36
+ /** Séparateur affiché entre les groupes (ex. "-") */
37
+ separator?: string
38
+ /** Nombre de cases par groupe (défaut : length → aucun séparateur) */
39
+ groupEvery?: number
40
+ /** Focus sur la première case au montage */
41
+ autofocus?: boolean
42
+ /** Coins arrondis : true = pilule, ou échelle xs|sm|md|lg (none = carré) */
43
+ radius?: RadiusProp
44
+ }
45
+
46
+ const props = withDefaults(defineProps<Props>(), {
47
+ modelValue: "",
48
+ length: 6,
49
+ outlined: false,
50
+ filled: false,
51
+ borderless: false,
52
+ dense: false,
53
+ disable: false,
54
+ readonly: false,
55
+ numeric: false,
56
+ password: false,
57
+ size: "md",
58
+ separator: "",
59
+ groupEvery: 0,
60
+ autofocus: false,
61
+ })
62
+
63
+ const emit = defineEmits<{
64
+ "update:modelValue": [value: string]
65
+ complete: [value: string]
66
+ focus: [event: FocusEvent, index: number]
67
+ blur: [event: FocusEvent, index: number]
68
+ }>()
69
+
70
+ const value = computed(() => props.modelValue ?? "")
71
+ const boxes = computed(() => Array.from({ length: props.length }, (_, i) => i))
72
+ const inputs = ref<(HTMLInputElement | null)[]>([])
73
+
74
+ const boxChar = (i: number) => value.value[i] ?? ""
75
+ const charType = computed(() => (props.password ? "password" : "text"))
76
+
77
+ // — Focus —
78
+ const focusBox = (i: number) => {
79
+ const el = inputs.value[i]
80
+ el?.focus()
81
+ el?.select()
82
+ }
83
+
84
+ const onBoxFocus = (i: number, e: FocusEvent) => {
85
+ ;(e.target as HTMLInputElement).select()
86
+ emit("focus", e, i)
87
+ }
88
+
89
+ // — Saisie —
90
+ const isValidChar = (ch: string) => (props.numeric ? /[0-9]/.test(ch) : ch !== " ")
91
+
92
+ const write = (i: number, chars: string[]) => {
93
+ const arr = value.value.split("")
94
+ for (let k = 0; k < chars.length && i + k < props.length; k++) {
95
+ if (!isValidChar(chars[k]!)) continue
96
+ arr[i + k] = chars[k]!
97
+ }
98
+ emit("update:modelValue", arr.join(""))
99
+ return Math.min(i + chars.length - 1, props.length - 1)
100
+ }
101
+
102
+ const onInput = (i: number, e: Event) => {
103
+ const el = e.target as HTMLInputElement
104
+ const text = el.value
105
+ el.value = "" // valeur pilotée : on remplit nous-mêmes
106
+ if (props.disable || props.readonly) return
107
+ if (!text) return
108
+ if (text.length > 1) {
109
+ // collage ou saisie multiple : remplit depuis la case i
110
+ const chars = text.replace(/\s/g, "").split("")
111
+ const last = write(i, chars)
112
+ focusBox(last)
113
+ return
114
+ }
115
+ const ch = text[0]!
116
+ if (!isValidChar(ch)) return
117
+ write(i, [ch])
118
+ if (i < props.length - 1) focusBox(i + 1)
119
+ }
120
+
121
+ const onPaste = (i: number, e: ClipboardEvent) => {
122
+ if (props.disable || props.readonly) return
123
+ e.preventDefault()
124
+ const text = e.clipboardData?.getData("text") ?? ""
125
+ const chars = text.replace(/\s/g, "").split("")
126
+ const last = write(i, chars)
127
+ focusBox(last)
128
+ }
129
+
130
+ // — Clavier —
131
+ const onKeydown = (i: number, e: KeyboardEvent) => {
132
+ if (props.disable || props.readonly) return
133
+ if (e.key === "Backspace") {
134
+ e.preventDefault()
135
+ if (boxChar(i)) {
136
+ const arr = value.value.split("")
137
+ arr[i] = ""
138
+ emit("update:modelValue", arr.join(""))
139
+ } else if (i > 0) {
140
+ const arr = value.value.split("")
141
+ arr[i - 1] = ""
142
+ emit("update:modelValue", arr.join(""))
143
+ focusBox(i - 1)
144
+ }
145
+ return
146
+ }
147
+ if (e.key === "Delete") {
148
+ e.preventDefault()
149
+ if (boxChar(i)) {
150
+ const arr = value.value.split("")
151
+ arr[i] = ""
152
+ emit("update:modelValue", arr.join(""))
153
+ }
154
+ return
155
+ }
156
+ if (e.key === "ArrowLeft" && i > 0) {
157
+ e.preventDefault()
158
+ focusBox(i - 1)
159
+ return
160
+ }
161
+ if (e.key === "ArrowRight" && i < props.length - 1) {
162
+ e.preventDefault()
163
+ focusBox(i + 1)
164
+ return
165
+ }
166
+ // Retaper le même caractère : l'événement input ne se déclenche pas
167
+ // (valeur identique) → on avance quand même.
168
+ if (e.key.length === 1 && isValidChar(e.key) && boxChar(i) === e.key) {
169
+ focusBox(i + 1)
170
+ }
171
+ }
172
+
173
+ // — Complétion —
174
+ watch(
175
+ value,
176
+ (v) => {
177
+ if (v.length === props.length && props.length > 0) emit("complete", v)
178
+ },
179
+ )
180
+
181
+ // — Styles —
182
+ const effectiveRadius = useRadius("QInputOtp", () => props.radius)
183
+
184
+ const rootClasses = computed(() =>
185
+ cn(
186
+ "q-input-otp",
187
+ props.outlined && "q-field--outlined",
188
+ props.filled && "q-field--filled",
189
+ props.borderless && "q-field--borderless",
190
+ props.dense && "q-field--dense",
191
+ props.error && "q-field--error",
192
+ props.disable && "q-field--disabled",
193
+ ),
194
+ )
195
+
196
+ const boxClasses = (i: number) =>
197
+ cn(
198
+ "q-input-otp__box",
199
+ `q-input-otp__box--${props.size}`,
200
+ boxChar(i) && "q-input-otp__box--filled",
201
+ props.error && "q-input-otp__box--error",
202
+ )
203
+
204
+ const showSeparator = (i: number) =>
205
+ props.separator && props.groupEvery > 0 && i > 0 && i % props.groupEvery === 0
206
+ </script>
207
+
208
+ <template>
209
+ <div class="q-input-otp" :class="rootClasses" :style="radiusStyle(effectiveRadius)">
210
+ <label v-if="label" class="q-field__label-stack">{{ label }}</label>
211
+ <div class="q-input-otp__boxes">
212
+ <template v-for="i in boxes" :key="i">
213
+ <span v-if="showSeparator(i)" class="q-input-otp__separator">{{ separator }}</span>
214
+ <input
215
+ :ref="(el) => (inputs[i] = el as HTMLInputElement | null)"
216
+ :class="boxClasses(i)"
217
+ :value="boxChar(i)"
218
+ :type="charType"
219
+ :inputmode="numeric ? 'numeric' : undefined"
220
+ :autocomplete="'one-time-code'"
221
+ :disabled="disable || undefined"
222
+ :readonly="readonly || undefined"
223
+ :tabindex="disable || readonly ? -1 : 0"
224
+ :aria-label="label ? label + ', character ' + (i + 1) : 'Character ' + (i + 1)"
225
+ @focus="onBoxFocus(i, $event)"
226
+ @blur="(e) => emit('blur', e, i)"
227
+ @input="onInput(i, $event)"
228
+ @keydown="onKeydown(i, $event)"
229
+ @paste="onPaste(i, $event)"
230
+ />
231
+ </template>
232
+ </div>
233
+ <div class="q-field__bottom">
234
+ <div v-if="error" class="q-field__error">{{ errorMessage }}</div>
235
+ <div v-else-if="hint" class="q-field__hint">{{ hint }}</div>
236
+ </div>
237
+ </div>
238
+ </template>
@@ -0,0 +1,67 @@
1
+ <script setup lang="ts">
2
+ // QInputPassword — champ mot de passe avec toggle afficher/masquer (œil / œil barré).
3
+ // Wrapper de QInput : toutes les props passent à travers, le type est piloté en interne.
4
+ import { computed, ref } from "vue"
5
+ import { Icon } from "@iconify/vue"
6
+ import { icons } from "../lib/icons"
7
+ import QInput from "./QInput.vue"
8
+ import { useRadius } from "../lib/useComponentProps"
9
+ import type { RadiusProp } from "../lib/useComponentProps"
10
+
11
+ interface Props {
12
+ modelValue?: string | number | null
13
+ label?: string
14
+ /** Label fixé au-dessus du champ */
15
+ stackLabel?: boolean
16
+ hint?: string
17
+ error?: boolean
18
+ errorMessage?: string
19
+ counter?: boolean
20
+ maxlength?: number
21
+ prefix?: string
22
+ suffix?: string
23
+ clearable?: boolean
24
+ outlined?: boolean
25
+ filled?: boolean
26
+ borderless?: boolean
27
+ dense?: boolean
28
+ disable?: boolean
29
+ readonly?: boolean
30
+ placeholder?: string
31
+ autocomplete?: string
32
+ /** Coins arrondis : true = pilule, ou échelle xs|sm|md|lg (none = carré) */
33
+ radius?: RadiusProp
34
+ }
35
+
36
+ const props = defineProps<Props>()
37
+
38
+ const show = ref(false)
39
+
40
+ // radius : prop explicite > composantProps.QInputPassword > composantProps.default
41
+ const effectiveRadius = useRadius("QInputPassword", () => props.radius)
42
+
43
+ const inputType = computed<"password" | "text">(() => (show.value ? "text" : "password"))
44
+
45
+ const toggleLabel = computed(() =>
46
+ show.value ? "Masquer le mot de passe" : "Afficher le mot de passe",
47
+ )
48
+ </script>
49
+
50
+ <template>
51
+ <q-input v-bind="{ ...$props, ...$attrs }" :radius="effectiveRadius" :type="inputType">
52
+ <template #append>
53
+ <button
54
+ v-if="!disable && !readonly"
55
+ type="button"
56
+ class="q-input-password__toggle"
57
+ :aria-label="toggleLabel"
58
+ @click="show = !show"
59
+ >
60
+ <Icon :icon="show ? icons.eye : icons.eyeOff" aria-hidden="true" />
61
+ </button>
62
+ </template>
63
+ <template v-if="$slots.prepend" #prepend><slot name="prepend" /></template>
64
+ <template v-if="$slots.hint" #hint><slot name="hint" /></template>
65
+ <template v-if="$slots.error" #error><slot name="error" /></template>
66
+ </q-input>
67
+ </template>
@@ -0,0 +1,281 @@
1
+ <script setup lang="ts">
2
+ // QInputTag — champ de tags : <q-input-tag v-model="tags" label="Emails" outlined />
3
+ // Entrée/virgule ajoute · Backspace/Delete retire · flèches pour naviguer entre les
4
+ // tags · double-clic (ou Entrée) pour éditer un tag · collage multi-tags (séparateurs).
5
+ import { computed, nextTick, ref } from "vue"
6
+ import { Icon } from "@iconify/vue"
7
+ import { icons } from "../lib/icons"
8
+ import { cn } from "../lib/utils"
9
+ import { radiusStyle, useRadius } from "../lib/useComponentProps"
10
+ import type { RadiusProp } from "../lib/useComponentProps"
11
+
12
+ interface Props {
13
+ /** Tags (v-model) */
14
+ modelValue?: string[]
15
+ /** Label affiché au-dessus du champ */
16
+ label?: string
17
+ placeholder?: string
18
+ hint?: string
19
+ error?: boolean
20
+ errorMessage?: string
21
+ /** Bordure visible */
22
+ outlined?: boolean
23
+ /** Fond gris, soulignement */
24
+ filled?: boolean
25
+ /** Aucune bordure */
26
+ borderless?: boolean
27
+ /** Coins arrondis : true = pilule, ou échelle xs|sm|md|lg (none = carré) */
28
+ radius?: RadiusProp
29
+ /** Hauteur réduite */
30
+ dense?: boolean
31
+ disable?: boolean
32
+ readonly?: boolean
33
+ /** Nombre max de tags (0 = illimité) */
34
+ maxTags?: number
35
+ /** Séparateurs de collage (défaut : virgule, point-virgule, retour ligne) */
36
+ separators?: string[]
37
+ }
38
+
39
+ const props = withDefaults(defineProps<Props>(), {
40
+ modelValue: () => [],
41
+ outlined: false,
42
+ filled: false,
43
+ borderless: false,
44
+ dense: false,
45
+ disable: false,
46
+ readonly: false,
47
+ maxTags: 0,
48
+ separators: () => [",", ";", "\n"],
49
+ })
50
+
51
+ const emit = defineEmits<{
52
+ "update:modelValue": [value: string[]]
53
+ focus: [event: FocusEvent]
54
+ blur: [event: FocusEvent]
55
+ add: [tag: string]
56
+ remove: [tag: string]
57
+ }>()
58
+
59
+ const inputEl = ref<HTMLInputElement | null>(null)
60
+ const editInputEl = ref<HTMLInputElement | null>(null)
61
+ const controlEl = ref<HTMLElement | null>(null)
62
+ const text = ref("")
63
+ const focusedTag = ref<number | null>(null)
64
+ const editing = ref<{ index: number; value: string } | null>(null)
65
+
66
+ const tags = computed(() => props.modelValue ?? [])
67
+
68
+ // — Ajout / retrait —
69
+ const addTag = (raw: string) => {
70
+ const t = raw.trim()
71
+ if (!t) return
72
+ if (props.maxTags > 0 && tags.value.length >= props.maxTags) return
73
+ if (tags.value.includes(t)) return
74
+ emit("update:modelValue", [...tags.value, t])
75
+ emit("add", t)
76
+ }
77
+
78
+ const addText = () => {
79
+ const t = text.value.trim()
80
+ text.value = ""
81
+ if (t) addTag(t)
82
+ }
83
+
84
+ const removeAt = (index: number) => {
85
+ const t = tags.value[index]
86
+ if (t === undefined) return
87
+ const next = [...tags.value]
88
+ next.splice(index, 1)
89
+ emit("update:modelValue", next)
90
+ emit("remove", t)
91
+ if (focusedTag.value === index) focusedTag.value = null
92
+ inputEl.value?.focus()
93
+ }
94
+
95
+ // — Saisie —
96
+ const onInputKeydown = (e: KeyboardEvent) => {
97
+ if (props.disable || props.readonly) return
98
+ if (e.key === "Enter" || e.key === ",") {
99
+ e.preventDefault()
100
+ addText()
101
+ }
102
+ else if (e.key === "Backspace" && text.value === "" && tags.value.length) {
103
+ e.preventDefault()
104
+ removeAt(tags.value.length - 1)
105
+ }
106
+ else if (e.key === "ArrowLeft" && text.value === "" && tags.value.length) {
107
+ const el = e.target as HTMLInputElement
108
+ if (el.selectionStart === 0) {
109
+ e.preventDefault()
110
+ focusChip(tags.value.length - 1)
111
+ }
112
+ }
113
+ }
114
+
115
+ // Collage : divise par les séparateurs → plusieurs tags
116
+ const onPaste = (e: ClipboardEvent) => {
117
+ if (props.disable || props.readonly) return
118
+ const raw = e.clipboardData?.getData("text") ?? ""
119
+ const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
120
+ const parts = raw.split(new RegExp(props.separators.map(esc).join("|")))
121
+ if (parts.length > 1) {
122
+ e.preventDefault()
123
+ for (const p of parts) addTag(p)
124
+ }
125
+ }
126
+
127
+ // — Navigation & édition des tags —
128
+ const focusChip = (index: number) => {
129
+ const chips = controlEl.value?.querySelectorAll<HTMLElement>(".q-input-tag__chip")
130
+ const el = chips?.[index]
131
+ if (el) {
132
+ el.focus()
133
+ focusedTag.value = index
134
+ }
135
+ else {
136
+ inputEl.value?.focus()
137
+ focusedTag.value = null
138
+ }
139
+ }
140
+
141
+ const tagKeydown = (index: number, e: KeyboardEvent) => {
142
+ if (e.key === "Backspace" || e.key === "Delete") {
143
+ e.preventDefault()
144
+ removeAt(index)
145
+ }
146
+ else if (e.key === "ArrowRight") {
147
+ e.preventDefault()
148
+ focusChip(index + 1)
149
+ }
150
+ else if (e.key === "ArrowLeft") {
151
+ e.preventDefault()
152
+ if (index === 0) inputEl.value?.focus()
153
+ else focusChip(index - 1)
154
+ }
155
+ else if (e.key === "Enter" || e.key === "F2") {
156
+ e.preventDefault()
157
+ startEdit(index)
158
+ }
159
+ }
160
+
161
+ const startEdit = (index: number) => {
162
+ editing.value = { index, value: tags.value[index] ?? "" }
163
+ nextTick(() => {
164
+ editInputEl.value?.focus()
165
+ editInputEl.value?.select()
166
+ })
167
+ }
168
+
169
+ const commitEdit = () => {
170
+ if (!editing.value) return
171
+ const { index, value } = editing.value
172
+ editing.value = null
173
+ const next = [...tags.value]
174
+ const t = value.trim()
175
+ if (!t) {
176
+ next.splice(index, 1)
177
+ emit("update:modelValue", next)
178
+ emit("remove", tags.value[index]!)
179
+ }
180
+ else if (t !== next[index]) {
181
+ next[index] = t
182
+ emit("update:modelValue", next)
183
+ }
184
+ inputEl.value?.focus()
185
+ }
186
+
187
+ const cancelEdit = () => {
188
+ editing.value = null
189
+ inputEl.value?.focus()
190
+ }
191
+
192
+ const editKeydown = (e: KeyboardEvent) => {
193
+ if (e.key === "Enter") {
194
+ e.preventDefault()
195
+ commitEdit()
196
+ }
197
+ else if (e.key === "Escape") {
198
+ e.preventDefault()
199
+ cancelEdit()
200
+ }
201
+ }
202
+
203
+ // — Radius & styles de champ —
204
+ const effectiveRadius = useRadius("QInputTag", () => props.radius)
205
+ const roundedStyle = computed(() => radiusStyle(effectiveRadius.value))
206
+
207
+ const fieldClasses = computed(() =>
208
+ cn(
209
+ "q-input-tag",
210
+ props.outlined && "q-field--outlined",
211
+ props.filled && "q-field--filled",
212
+ props.borderless && "q-field--borderless",
213
+ effectiveRadius.value === true && "q-field--rounded",
214
+ props.dense && "q-field--dense",
215
+ props.error && "q-field--error",
216
+ props.disable && "q-field--disabled",
217
+ ),
218
+ )
219
+ </script>
220
+
221
+ <template>
222
+ <div class="q-input-tag" :class="fieldClasses" :style="roundedStyle">
223
+ <label v-if="label" class="q-field__label-stack">{{ label }}</label>
224
+ <div ref="controlEl" class="q-field__control q-input-tag__control" @click="inputEl?.focus()">
225
+ <template v-for="(t, i) in tags" :key="`${t}-${i}`">
226
+ <span
227
+ v-if="editing?.index !== i"
228
+ class="q-input-tag__chip"
229
+ :class="{ 'q-input-tag__chip--focused': focusedTag === i }"
230
+ role="button"
231
+ :tabindex="disable || readonly ? -1 : 0"
232
+ @click.stop="focusedTag = i"
233
+ @dblclick.stop="startEdit(i)"
234
+ @keydown="(e) => tagKeydown(i, e)"
235
+ @focus="focusedTag = i"
236
+ @blur="focusedTag = null"
237
+ >
238
+ <span class="q-input-tag__chip-label">{{ t }}</span>
239
+ <button
240
+ v-if="!disable && !readonly"
241
+ type="button"
242
+ class="q-input-tag__chip-remove"
243
+ :aria-label="`Retirer ${t}`"
244
+ @click.stop="removeAt(i)"
245
+ >
246
+ <Icon :icon="icons.x" aria-hidden="true" />
247
+ </button>
248
+ </span>
249
+ <input
250
+ v-else
251
+ ref="editInputEl"
252
+ class="q-input-tag__edit"
253
+ :value="editing.value"
254
+ @input="editing.value = ($event.target as HTMLInputElement).value"
255
+ @keydown="editKeydown"
256
+ @blur="commitEdit"
257
+ />
258
+ </template>
259
+ <input
260
+ ref="inputEl"
261
+ class="q-field__native q-input-tag__native"
262
+ :value="text"
263
+ :placeholder="tags.length ? undefined : placeholder"
264
+ :disabled="disable || undefined"
265
+ :readonly="readonly || undefined"
266
+ @input="text = ($event.target as HTMLInputElement).value"
267
+ @keydown="onInputKeydown"
268
+ @paste="onPaste"
269
+ @focus="(e) => emit('focus', e)"
270
+ @blur="(e) => {
271
+ if (text.trim()) addText()
272
+ emit('blur', e)
273
+ }"
274
+ />
275
+ </div>
276
+ <div class="q-field__bottom">
277
+ <div v-if="error" class="q-field__error">{{ errorMessage }}</div>
278
+ <div v-else-if="hint" class="q-field__hint">{{ hint }}</div>
279
+ </div>
280
+ </div>
281
+ </template>
@@ -0,0 +1,119 @@
1
+ <script setup lang="ts">
2
+ // QIntersection — révèle son contenu quand il entre dans le viewport (API Quasar) :
3
+ // <q-intersection transition="fade" once @show="…">
4
+ // IntersectionObserver + transition d'apparition (fade, scale, slide-*, flip…).
5
+ // Props : once, threshold, root-margin, margin, root, disable, intersected (v-model).
6
+ import { onBeforeUnmount, onMounted, ref, watch } from "vue"
7
+
8
+ interface Props {
9
+ /** État d'intersection (v-model) — reflète si le contenu est visible */
10
+ modelValue?: boolean
11
+ /** Ne déclenche l'intersection qu'une fois (défaut true) */
12
+ once?: boolean
13
+ /** Transition d'apparition : "fade" (défaut) | "scale" | "slide-up" | "slide-down" |
14
+ * "slide-left" | "slide-right" | "flip" | "none"/"" (aucune) — ou nom custom
15
+ * (classes `q-intersection-{nom}-enter-active`…) */
16
+ transition?: string
17
+ /** Seuil de visibilité : 0 = un pixel visible, 1 = entièrement visible */
18
+ threshold?: number
19
+ /** rootMargin de l'IntersectionObserver (ex. "50px 0px") */
20
+ rootMargin?: string
21
+ /** Marge autour de l'élément (agrandit la zone de détection) */
22
+ margin?: string
23
+ /** Désactive l'observation (le contenu reste visible) */
24
+ disable?: boolean
25
+ /** Racine de l'observation (défaut : viewport) */
26
+ root?: Element | null
27
+ }
28
+
29
+ const props = withDefaults(defineProps<Props>(), {
30
+ modelValue: false,
31
+ once: true,
32
+ transition: "fade",
33
+ threshold: 0,
34
+ rootMargin: "",
35
+ margin: "",
36
+ disable: false,
37
+ root: null,
38
+ })
39
+
40
+ const emit = defineEmits<{
41
+ "update:modelValue": [value: boolean]
42
+ show: []
43
+ hide: []
44
+ }>()
45
+
46
+ const rootEl = ref<HTMLElement | null>(null)
47
+ const visible = ref(props.modelValue)
48
+ let observer: IntersectionObserver | null = null
49
+ let done = false
50
+
51
+ const setVisible = (v: boolean) => {
52
+ if (visible.value === v) return
53
+ visible.value = v
54
+ emit("update:modelValue", v)
55
+ if (v) emit("show")
56
+ else emit("hide")
57
+ }
58
+
59
+ const onIntersect: IntersectionObserverCallback = ([entry]) => {
60
+ if (props.disable || !entry) return
61
+ if (entry.isIntersecting) {
62
+ setVisible(true)
63
+ if (props.once) {
64
+ done = true
65
+ observer?.disconnect()
66
+ }
67
+ } else if (!props.once) {
68
+ setVisible(false)
69
+ }
70
+ }
71
+
72
+ const createObserver = () => {
73
+ if (props.disable) return
74
+ if (typeof IntersectionObserver === "undefined" || !rootEl.value) {
75
+ // Environnement sans IntersectionObserver (SSR/vieux) : contenu visible
76
+ setVisible(true)
77
+ return
78
+ }
79
+ observer = new IntersectionObserver(onIntersect, {
80
+ threshold: props.threshold,
81
+ rootMargin: props.rootMargin || undefined,
82
+ root: props.root ?? null,
83
+ })
84
+ observer.observe(rootEl.value)
85
+ }
86
+
87
+ onMounted(() => {
88
+ if (!done) createObserver()
89
+ })
90
+
91
+ watch(
92
+ () => props.disable,
93
+ (d) => {
94
+ if (d) observer?.disconnect()
95
+ else if (!done) createObserver()
96
+ },
97
+ )
98
+
99
+ onBeforeUnmount(() => observer?.disconnect())
100
+ </script>
101
+
102
+ <template>
103
+ <div ref="rootEl" class="q-intersection" :style="margin ? { margin } : undefined">
104
+ <slot v-if="!visible && $slots.loading" name="loading" />
105
+ <!-- v-show (contenu toujours rendu) : le slot expose { active } pour
106
+ conditionner l'intérieur selon l'état d'intersection -->
107
+ <Transition
108
+ v-if="transition && transition !== 'none'"
109
+ :name="`q-intersection-${transition}`"
110
+ >
111
+ <div v-show="visible" class="q-intersection__content">
112
+ <slot :active="visible" />
113
+ </div>
114
+ </Transition>
115
+ <div v-else v-show="visible" class="q-intersection__content">
116
+ <slot :active="visible" />
117
+ </div>
118
+ </div>
119
+ </template>