@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,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>
|