@dnax/ui 0.0.72 → 0.0.73
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/components/QInput.vue +60 -21
- package/components/QInputTextarea.vue +211 -0
- package/index.ts +5 -0
- package/lib/textarea.test.ts +80 -0
- package/lib/textarea.ts +68 -0
- package/lib/useTextareaAutosize.ts +83 -0
- package/package.json +1 -1
- package/styles/main.css +56 -0
package/components/QInput.vue
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
// QInput — API Quasar : <q-input v-model="…" label="Email" outlined dense clearable counter maxlength="60" hint error error-message />
|
|
3
3
|
// Variants : outlined (défaut) | filled | borderless ; le label est toujours affiché au-dessus du champ.
|
|
4
|
-
|
|
4
|
+
// Multiligne : `autogrow` (auto-extension plafonnée à `max-rows`), `rows` (lignes visibles)
|
|
5
|
+
// ou `resize` — pour un champ multiligne complet, préférer `<q-input-textarea>`.
|
|
6
|
+
import { computed, ref } from "vue"
|
|
5
7
|
import { Icon } from "@iconify/vue"
|
|
6
8
|
import { icons } from "../lib/icons"
|
|
7
9
|
import { cn } from "../lib/utils"
|
|
8
10
|
import { radiusStyle, useRadius } from "../lib/useComponentProps"
|
|
9
11
|
import type { RadiusProp } from "../lib/useComponentProps"
|
|
12
|
+
import { useTextareaAutosize } from "../lib/useTextareaAutosize"
|
|
10
13
|
|
|
11
14
|
interface Props {
|
|
12
15
|
modelValue?: string | number | null
|
|
@@ -33,7 +36,7 @@ interface Props {
|
|
|
33
36
|
iconLeft?: string
|
|
34
37
|
/** Icône Iconify à droite (remplacée par le slot #append s'il est fourni) */
|
|
35
38
|
iconRight?: string
|
|
36
|
-
/** Masque de saisie : # = chiffre, A = lettre, N = alphanumérique, X = quelconque (ex. "##-##-##") */
|
|
39
|
+
/** Masque de saisie : # = chiffre, A = lettre, N = alphanumérique, X = quelconque (ex. "##-##-##") — ignoré en mode textarea */
|
|
37
40
|
mask?: string
|
|
38
41
|
/** Remplit les emplacements vides par "_" (défaut : la saisie s'arrête au dernier caractère) */
|
|
39
42
|
fillMask?: boolean
|
|
@@ -51,8 +54,16 @@ interface Props {
|
|
|
51
54
|
radius?: RadiusProp
|
|
52
55
|
/** Hauteur réduite */
|
|
53
56
|
dense?: boolean
|
|
54
|
-
/** Textarea à hauteur auto */
|
|
57
|
+
/** Textarea à hauteur auto, plafonné à `maxRows` lignes */
|
|
55
58
|
autogrow?: boolean
|
|
59
|
+
/** Nombre de lignes visibles (textarea) — `> 1` bascule le champ en multiligne */
|
|
60
|
+
rows?: number
|
|
61
|
+
/** Plafond d'auto-extension en lignes, 0 = illimité : au-delà, le champ défile */
|
|
62
|
+
maxRows?: number
|
|
63
|
+
/** Redimensionnement manuel du textarea (sans effet en `autogrow`) */
|
|
64
|
+
resize?: "none" | "vertical" | "both"
|
|
65
|
+
/** Attribut autocomplete transmis au champ natif */
|
|
66
|
+
autocomplete?: string
|
|
56
67
|
disable?: boolean
|
|
57
68
|
readonly?: boolean
|
|
58
69
|
placeholder?: string
|
|
@@ -69,6 +80,9 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
69
80
|
borderless: false,
|
|
70
81
|
dense: false,
|
|
71
82
|
autogrow: false,
|
|
83
|
+
rows: 1,
|
|
84
|
+
maxRows: 0,
|
|
85
|
+
resize: "none",
|
|
72
86
|
disable: false,
|
|
73
87
|
readonly: false,
|
|
74
88
|
fillMask: false,
|
|
@@ -93,6 +107,17 @@ const roundedStyle = computed(() => radiusStyle(effectiveRadius.value))
|
|
|
93
107
|
|
|
94
108
|
const value = computed(() => props.modelValue ?? "")
|
|
95
109
|
|
|
110
|
+
// Le champ est multiligne dès qu'on demande plusieurs lignes, une auto-extension ou
|
|
111
|
+
// un redimensionnement manuel — un `<input>` n'a ni hauteur de plusieurs lignes ni
|
|
112
|
+
// poignée de redimensionnement.
|
|
113
|
+
const isTextarea = computed(
|
|
114
|
+
() => props.autogrow || props.rows > 1 || props.resize !== "none",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
// Un masque de saisie n'a pas de sens sur du multiligne (il supprime les retours à
|
|
118
|
+
// la ligne) : il est ignoré en mode textarea.
|
|
119
|
+
const maskEnabled = computed(() => Boolean(props.mask) && !isTextarea.value)
|
|
120
|
+
|
|
96
121
|
// ═════════ Masque de saisie : # chiffre, A lettre, N alphanumérique, X quelconque ═════════
|
|
97
122
|
const isToken = (c: string): boolean => c === "#" || c === "A" || c === "N" || c === "X"
|
|
98
123
|
|
|
@@ -107,7 +132,7 @@ const matchesToken = (tok: string, c: string): boolean =>
|
|
|
107
132
|
|
|
108
133
|
/** Extrait les caractères réels (emplacements tokens) d'une valeur affichée masquée. */
|
|
109
134
|
const unmask = (v: string, fill: boolean): string => {
|
|
110
|
-
if (!props.mask) return v
|
|
135
|
+
if (!maskEnabled.value || !props.mask) return v
|
|
111
136
|
let out = ""
|
|
112
137
|
let vi = 0
|
|
113
138
|
for (const ch of props.mask) {
|
|
@@ -126,7 +151,7 @@ const unmask = (v: string, fill: boolean): string => {
|
|
|
126
151
|
|
|
127
152
|
/** Applique le masque à une chaîne de caractères bruts (littéraux insérés, caractères invalides sautés). */
|
|
128
153
|
const mask = (raw: string, fill: boolean): string => {
|
|
129
|
-
if (!props.mask) return raw
|
|
154
|
+
if (!maskEnabled.value || !props.mask) return raw
|
|
130
155
|
let out = ""
|
|
131
156
|
let ri = 0
|
|
132
157
|
for (const ch of props.mask) {
|
|
@@ -151,7 +176,7 @@ const mask = (raw: string, fill: boolean): string => {
|
|
|
151
176
|
/** Valeur affichée dans le champ : applique le masque au modelValue (brut si unmaskedValue). */
|
|
152
177
|
const displayValue = computed(() => {
|
|
153
178
|
const v = String(props.modelValue ?? "")
|
|
154
|
-
if (!
|
|
179
|
+
if (!maskEnabled.value) return v
|
|
155
180
|
const raw = props.unmaskedValue ? v : unmask(v, props.fillMask)
|
|
156
181
|
return mask(raw, props.fillMask)
|
|
157
182
|
})
|
|
@@ -169,24 +194,33 @@ const fieldClasses = computed(() =>
|
|
|
169
194
|
props.error && "q-field--error",
|
|
170
195
|
props.disable && "q-field--disabled",
|
|
171
196
|
floatActive.value && "q-field--float",
|
|
197
|
+
isTextarea.value && "q-input--textarea",
|
|
172
198
|
),
|
|
173
199
|
)
|
|
174
200
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
201
|
+
// Auto-extension : la mesure et le plafond `maxRows` sont délégués au composable
|
|
202
|
+
// (calcul pur dans `lib/textarea.ts`). `observeWidth: false` — en mode `<input>`
|
|
203
|
+
// l'élément observé serait remplacé au changement de mode.
|
|
204
|
+
const { resize } = useTextareaAutosize(nativeEl, {
|
|
205
|
+
enabled: () => props.autogrow,
|
|
206
|
+
maxRows: () => props.maxRows,
|
|
207
|
+
sources: () => [value.value, props.rows, props.maxRows, props.autogrow],
|
|
208
|
+
observeWidth: false,
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
// `resize` exposé (onglet Methods de la doc) : re-mesure forcée après un
|
|
212
|
+
// changement de contenu fait hors du cycle normal (ex. valeur posée par le parent).
|
|
213
|
+
defineExpose({ resize })
|
|
183
214
|
|
|
184
|
-
|
|
215
|
+
// `resize` n'a de sens que sans auto-extension (sinon les deux pilotent la hauteur).
|
|
216
|
+
const textareaStyle = computed(() =>
|
|
217
|
+
props.resize === "none" || props.autogrow ? undefined : { resize: props.resize },
|
|
218
|
+
)
|
|
185
219
|
|
|
186
220
|
const onInput = (e: Event) => {
|
|
187
221
|
const el = e.target as HTMLInputElement | HTMLTextAreaElement
|
|
188
222
|
const raw = el.value
|
|
189
|
-
if (!
|
|
223
|
+
if (!maskEnabled.value) {
|
|
190
224
|
emit("update:modelValue", raw)
|
|
191
225
|
return
|
|
192
226
|
}
|
|
@@ -218,13 +252,17 @@ const showClear = computed(
|
|
|
218
252
|
() => props.clearable && !props.disable && !props.readonly && value.value !== "",
|
|
219
253
|
)
|
|
220
254
|
|
|
221
|
-
const
|
|
222
|
-
type: props.type,
|
|
255
|
+
const commonAttrs = computed(() => ({
|
|
223
256
|
maxlength: props.maxlength,
|
|
224
257
|
placeholder: props.placeholder,
|
|
258
|
+
autocomplete: props.autocomplete,
|
|
225
259
|
disabled: props.disable || undefined,
|
|
226
260
|
readonly: props.readonly || undefined,
|
|
227
261
|
}))
|
|
262
|
+
|
|
263
|
+
// `type` n'est valide que sur `<input>` : sur un `<textarea>` c'est un attribut
|
|
264
|
+
// invalide (et sans effet) — on ne le transmet pas.
|
|
265
|
+
const inputAttrs = computed(() => ({ ...commonAttrs.value, type: props.type }))
|
|
228
266
|
</script>
|
|
229
267
|
|
|
230
268
|
<template>
|
|
@@ -236,12 +274,13 @@ const inputAttrs = computed(() => ({
|
|
|
236
274
|
</slot>
|
|
237
275
|
<span v-if="prefix" class="q-field__prefix">{{ prefix }}</span>
|
|
238
276
|
<textarea
|
|
239
|
-
v-if="
|
|
277
|
+
v-if="isTextarea"
|
|
240
278
|
ref="nativeEl"
|
|
241
279
|
class="q-field__native"
|
|
242
280
|
:value="displayValue"
|
|
243
|
-
rows="
|
|
244
|
-
|
|
281
|
+
:rows="rows"
|
|
282
|
+
:style="textareaStyle"
|
|
283
|
+
v-bind="commonAttrs"
|
|
245
284
|
@input="onInput"
|
|
246
285
|
@focus="onFocus"
|
|
247
286
|
@blur="onBlur"
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// QInputTextarea — champ multiligne dédié : <q-input-textarea v-model="bio" label="Bio" :rows="3" />
|
|
3
|
+
// Contrairement à `q-input` (qui bascule entre `<input>` et `<textarea>`), ce composant
|
|
4
|
+
// est un textarea **seul** : lignes visibles (`rows`), plafond d'auto-extension
|
|
5
|
+
// (`maxRows`), redimensionnement manuel (`resize`), compteur et états — avec le
|
|
6
|
+
// vocabulaire de champ commun (outlined / filled / borderless / dense / radius / error).
|
|
7
|
+
import { computed, ref } from "vue"
|
|
8
|
+
import { Icon } from "@iconify/vue"
|
|
9
|
+
import { icons } from "../lib/icons"
|
|
10
|
+
import { cn } from "../lib/utils"
|
|
11
|
+
import { radiusStyle, useRadius } from "../lib/useComponentProps"
|
|
12
|
+
import type { RadiusProp } from "../lib/useComponentProps"
|
|
13
|
+
import { useTextareaAutosize } from "../lib/useTextareaAutosize"
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
modelValue?: string | number | null
|
|
17
|
+
/** Label affiché au-dessus du champ */
|
|
18
|
+
label?: string
|
|
19
|
+
/** Aide affichée sous le champ */
|
|
20
|
+
hint?: string
|
|
21
|
+
/** État d'erreur */
|
|
22
|
+
error?: boolean
|
|
23
|
+
/** Message d'erreur (affiché quand error) */
|
|
24
|
+
errorMessage?: string
|
|
25
|
+
/** Affiche le compteur de caractères (nécessite maxlength) */
|
|
26
|
+
counter?: boolean
|
|
27
|
+
maxlength?: number
|
|
28
|
+
/** Nombre de lignes visibles (hauteur du champ hors auto-extension) */
|
|
29
|
+
rows?: number
|
|
30
|
+
/** Plafond d'auto-extension en lignes, 0 = illimité : au-delà, le champ défile */
|
|
31
|
+
maxRows?: number
|
|
32
|
+
/** Le champ grandit avec le contenu jusqu'à `maxRows`, puis défile */
|
|
33
|
+
autogrow?: boolean
|
|
34
|
+
/** Redimensionnement manuel (sans effet en `autogrow`) */
|
|
35
|
+
resize?: "none" | "vertical" | "both"
|
|
36
|
+
/** Bouton d'effacement */
|
|
37
|
+
clearable?: boolean
|
|
38
|
+
/** Bordure visible */
|
|
39
|
+
outlined?: boolean
|
|
40
|
+
/** Fond gris, soulignement */
|
|
41
|
+
filled?: boolean
|
|
42
|
+
/** Aucune bordure */
|
|
43
|
+
borderless?: boolean
|
|
44
|
+
/** Coins arrondis : true = pilule, ou échelle xs|sm|md|lg (none = carré) */
|
|
45
|
+
radius?: RadiusProp
|
|
46
|
+
/** Hauteur réduite */
|
|
47
|
+
dense?: boolean
|
|
48
|
+
disable?: boolean
|
|
49
|
+
readonly?: boolean
|
|
50
|
+
placeholder?: string
|
|
51
|
+
/** Préfixe dans le champ */
|
|
52
|
+
prefix?: string
|
|
53
|
+
/** Suffixe dans le champ */
|
|
54
|
+
suffix?: string
|
|
55
|
+
/** Icône Iconify à gauche (remplacée par le slot #prepend s'il est fourni) */
|
|
56
|
+
iconLeft?: string
|
|
57
|
+
/** Icône Iconify à droite (remplacée par le slot #append s'il est fourni) */
|
|
58
|
+
iconRight?: string
|
|
59
|
+
/** Attribut autocomplete du champ natif */
|
|
60
|
+
autocomplete?: string
|
|
61
|
+
/** Correction orthographique du navigateur */
|
|
62
|
+
spellcheck?: boolean
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
66
|
+
rows: 3,
|
|
67
|
+
maxRows: 0,
|
|
68
|
+
autogrow: false,
|
|
69
|
+
resize: "none",
|
|
70
|
+
error: false,
|
|
71
|
+
counter: false,
|
|
72
|
+
clearable: false,
|
|
73
|
+
outlined: false,
|
|
74
|
+
filled: false,
|
|
75
|
+
borderless: false,
|
|
76
|
+
dense: false,
|
|
77
|
+
disable: false,
|
|
78
|
+
readonly: false,
|
|
79
|
+
spellcheck: false,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const emit = defineEmits<{
|
|
83
|
+
"update:modelValue": [value: string]
|
|
84
|
+
focus: [event: FocusEvent]
|
|
85
|
+
blur: [event: FocusEvent]
|
|
86
|
+
clear: []
|
|
87
|
+
keyup: [event: KeyboardEvent]
|
|
88
|
+
keydown: [event: KeyboardEvent]
|
|
89
|
+
}>()
|
|
90
|
+
|
|
91
|
+
const focused = ref(false)
|
|
92
|
+
const nativeEl = ref<HTMLTextAreaElement | null>(null)
|
|
93
|
+
|
|
94
|
+
// radius : prop explicite > composantProps.QInputTextarea ; échelle → --q-radius
|
|
95
|
+
const effectiveRadius = useRadius("QInputTextarea", () => props.radius)
|
|
96
|
+
const roundedStyle = computed(() => radiusStyle(effectiveRadius.value))
|
|
97
|
+
|
|
98
|
+
const value = computed(() => String(props.modelValue ?? ""))
|
|
99
|
+
|
|
100
|
+
const fieldClasses = computed(() =>
|
|
101
|
+
cn(
|
|
102
|
+
"q-input-textarea",
|
|
103
|
+
props.outlined && "q-field--outlined",
|
|
104
|
+
props.filled && "q-field--filled",
|
|
105
|
+
props.borderless && "q-field--borderless",
|
|
106
|
+
effectiveRadius.value === true && "q-field--rounded",
|
|
107
|
+
props.dense && "q-field--dense",
|
|
108
|
+
props.error && "q-field--error",
|
|
109
|
+
props.disable && "q-field--disabled",
|
|
110
|
+
focused.value && "q-field--float",
|
|
111
|
+
props.autogrow && "q-input-textarea--autogrow",
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
// Auto-extension : mesure + plafond `maxRows` (calcul pur dans `lib/textarea.ts`),
|
|
116
|
+
// re-mesurée au montage, à chaque changement de valeur/lignes et au changement de
|
|
117
|
+
// largeur (le texte se replie autrement).
|
|
118
|
+
const { resize } = useTextareaAutosize(nativeEl, {
|
|
119
|
+
enabled: () => props.autogrow,
|
|
120
|
+
maxRows: () => props.maxRows,
|
|
121
|
+
sources: () => [value.value, props.rows, props.maxRows, props.autogrow],
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// `resize` manuel inutile en auto-extension : les deux pilotent la hauteur.
|
|
125
|
+
const textareaStyle = computed(() =>
|
|
126
|
+
props.resize === "none" || props.autogrow ? undefined : { resize: props.resize },
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
const showClear = computed(
|
|
130
|
+
() => props.clearable && !props.disable && !props.readonly && value.value !== "",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
const onInput = (e: Event) => emit("update:modelValue", (e.target as HTMLTextAreaElement).value)
|
|
134
|
+
|
|
135
|
+
const onFocus = (e: FocusEvent) => {
|
|
136
|
+
focused.value = true
|
|
137
|
+
emit("focus", e)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const onBlur = (e: FocusEvent) => {
|
|
141
|
+
focused.value = false
|
|
142
|
+
emit("blur", e)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const onClear = () => {
|
|
146
|
+
emit("update:modelValue", "")
|
|
147
|
+
emit("clear")
|
|
148
|
+
void resize()
|
|
149
|
+
nativeEl.value?.focus()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
defineExpose({
|
|
153
|
+
focus: () => nativeEl.value?.focus(),
|
|
154
|
+
blur: () => nativeEl.value?.blur(),
|
|
155
|
+
resize: () => void resize(),
|
|
156
|
+
})
|
|
157
|
+
</script>
|
|
158
|
+
|
|
159
|
+
<template>
|
|
160
|
+
<div class="q-input-textarea" :class="fieldClasses" :style="roundedStyle">
|
|
161
|
+
<label v-if="label" class="q-field__label-stack">{{ label }}</label>
|
|
162
|
+
<div class="q-field__control">
|
|
163
|
+
<slot name="prepend">
|
|
164
|
+
<Icon v-if="iconLeft" :icon="iconLeft" class="q-field__icon" aria-hidden="true" />
|
|
165
|
+
</slot>
|
|
166
|
+
<span v-if="prefix" class="q-field__prefix">{{ prefix }}</span>
|
|
167
|
+
<textarea
|
|
168
|
+
ref="nativeEl"
|
|
169
|
+
class="q-field__native"
|
|
170
|
+
:value="value"
|
|
171
|
+
:rows="rows"
|
|
172
|
+
:style="textareaStyle"
|
|
173
|
+
:maxlength="maxlength"
|
|
174
|
+
:placeholder="placeholder"
|
|
175
|
+
:autocomplete="autocomplete"
|
|
176
|
+
:spellcheck="spellcheck"
|
|
177
|
+
:disabled="disable || undefined"
|
|
178
|
+
:readonly="readonly || undefined"
|
|
179
|
+
@input="onInput"
|
|
180
|
+
@focus="onFocus"
|
|
181
|
+
@blur="onBlur"
|
|
182
|
+
@keyup="(e) => emit('keyup', e)"
|
|
183
|
+
@keydown="(e) => emit('keydown', e)"
|
|
184
|
+
/>
|
|
185
|
+
<span v-if="suffix" class="q-field__suffix">{{ suffix }}</span>
|
|
186
|
+
<button
|
|
187
|
+
v-if="showClear"
|
|
188
|
+
class="q-field__clear"
|
|
189
|
+
type="button"
|
|
190
|
+
aria-label="Effacer"
|
|
191
|
+
@click="onClear"
|
|
192
|
+
>
|
|
193
|
+
<Icon :icon="icons.x" aria-hidden="true" />
|
|
194
|
+
</button>
|
|
195
|
+
<slot name="append">
|
|
196
|
+
<Icon v-if="iconRight" :icon="iconRight" class="q-field__icon" aria-hidden="true" />
|
|
197
|
+
</slot>
|
|
198
|
+
</div>
|
|
199
|
+
<div class="q-field__bottom">
|
|
200
|
+
<div v-if="error" class="q-field__error">
|
|
201
|
+
<slot name="error">{{ errorMessage }}</slot>
|
|
202
|
+
</div>
|
|
203
|
+
<div v-else-if="hint || $slots.hint" class="q-field__hint">
|
|
204
|
+
<slot name="hint">{{ hint }}</slot>
|
|
205
|
+
</div>
|
|
206
|
+
<div v-if="counter && maxlength" class="q-field__counter">
|
|
207
|
+
{{ value.length }}/{{ maxlength }}
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
</template>
|
package/index.ts
CHANGED
|
@@ -72,6 +72,7 @@ export { default as QInputCurrency } from "./components/QInputCurrency.vue"
|
|
|
72
72
|
export { default as QInputOtp } from "./components/QInputOtp.vue"
|
|
73
73
|
export { default as QInputPassword } from "./components/QInputPassword.vue"
|
|
74
74
|
export { default as QInputTag } from "./components/QInputTag.vue"
|
|
75
|
+
export { default as QInputTextarea } from "./components/QInputTextarea.vue"
|
|
75
76
|
export { default as QInteract } from "./components/QInteract.vue"
|
|
76
77
|
export { default as QIntersection } from "./components/QIntersection.vue"
|
|
77
78
|
export { default as QItem } from "./components/QItem.vue"
|
|
@@ -203,6 +204,10 @@ export { DEFAULT_LAYOUT_VIEW, parseView, qLayoutKey } from "./lib/layout"
|
|
|
203
204
|
export type { LayoutView, LayoutZone, LayoutZonePlacement, QLayoutContext } from "./lib/layout"
|
|
204
205
|
export { vRipple } from "./lib/ripple"
|
|
205
206
|
export type { RippleOptions, RippleValue } from "./lib/ripple"
|
|
207
|
+
export { textareaCap, textareaMetrics, textareaSizing } from "./lib/textarea"
|
|
208
|
+
export type { TextareaMetrics, TextareaSizing, TextareaComputedStyle } from "./lib/textarea"
|
|
209
|
+
export { useTextareaAutosize } from "./lib/useTextareaAutosize"
|
|
210
|
+
export type { TextareaAutosize, UseTextareaAutosizeOptions } from "./lib/useTextareaAutosize"
|
|
206
211
|
export { chartToECharts, channelValue, pickFromEvent, tableCsv, tableModel, textValue, tokenCss } from "./lib/chart"
|
|
207
212
|
export type {
|
|
208
213
|
QChartAxis,
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Auto-extension d'un textarea : le plafond `maxRows` doit être **respecté au pixel**
|
|
2
|
+
// (hauteur = lignes × line-height + paddings) et le défilement ne s'activer que
|
|
3
|
+
// lorsque le contenu dépasse — sinon un champ collé à son plafond clignote.
|
|
4
|
+
import { describe, expect, it } from "bun:test"
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_LINE_HEIGHT_RATIO,
|
|
7
|
+
textareaCap,
|
|
8
|
+
textareaMetrics,
|
|
9
|
+
textareaSizing,
|
|
10
|
+
} from "./textarea"
|
|
11
|
+
|
|
12
|
+
const metrics = { fontSize: 14, lineHeight: 20, paddingTop: 0, paddingBottom: 0 }
|
|
13
|
+
|
|
14
|
+
describe("textareaMetrics", () => {
|
|
15
|
+
it("résout des valeurs px", () => {
|
|
16
|
+
expect(
|
|
17
|
+
textareaMetrics({
|
|
18
|
+
fontSize: "14px",
|
|
19
|
+
lineHeight: "20px",
|
|
20
|
+
paddingTop: "8px",
|
|
21
|
+
paddingBottom: "12px",
|
|
22
|
+
}),
|
|
23
|
+
).toEqual({ fontSize: 14, lineHeight: 20, paddingTop: 8, paddingBottom: 12 })
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("replie `line-height: normal` sur fontSize × 1.4", () => {
|
|
27
|
+
const m = textareaMetrics({ fontSize: "16px", lineHeight: "normal" })
|
|
28
|
+
expect(m.lineHeight).toBeCloseTo(16 * DEFAULT_LINE_HEIGHT_RATIO)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("replie les valeurs absentes ou invalides", () => {
|
|
32
|
+
const m = textareaMetrics({})
|
|
33
|
+
expect(m).toEqual({ fontSize: 14, lineHeight: 14 * DEFAULT_LINE_HEIGHT_RATIO, paddingTop: 0, paddingBottom: 0 })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("ignore les paddings négatifs ou non numériques", () => {
|
|
37
|
+
expect(textareaMetrics({ paddingTop: "-4px", paddingBottom: "auto" })).toEqual({
|
|
38
|
+
fontSize: 14,
|
|
39
|
+
lineHeight: 14 * DEFAULT_LINE_HEIGHT_RATIO,
|
|
40
|
+
paddingTop: 0,
|
|
41
|
+
paddingBottom: 0,
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe("textareaCap", () => {
|
|
47
|
+
it("vaut lignes × line-height + paddings", () => {
|
|
48
|
+
expect(textareaCap({ ...metrics, paddingTop: 10, paddingBottom: 6 }, 3)).toBe(76)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("vaut Infinity sans plafond", () => {
|
|
52
|
+
expect(textareaCap(metrics, 0)).toBe(Number.POSITIVE_INFINITY)
|
|
53
|
+
expect(textareaCap(metrics, -2)).toBe(Number.POSITIVE_INFINITY)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe("textareaSizing", () => {
|
|
58
|
+
it("prend la hauteur du contenu quand il tient sous le plafond", () => {
|
|
59
|
+
expect(textareaSizing(metrics, 40, 5)).toEqual({ height: 40, overflowY: "hidden", capped: false })
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("plafonne et active le défilement au-delà de maxRows", () => {
|
|
63
|
+
expect(textareaSizing(metrics, 300, 5)).toEqual({ height: 100, overflowY: "auto", capped: true })
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("ne plafonne pas quand maxRows vaut 0", () => {
|
|
67
|
+
expect(textareaSizing(metrics, 300, 0)).toEqual({ height: 300, overflowY: "hidden", capped: false })
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("défile dès que le contenu dépasse, sans marge", () => {
|
|
71
|
+
// 100 px pile = plafond : pas encore de défilement (évite le clignotement)
|
|
72
|
+
expect(textareaSizing(metrics, 100, 5).overflowY).toBe("hidden")
|
|
73
|
+
expect(textareaSizing(metrics, 101, 5).overflowY).toBe("auto")
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("normalise un scrollHeight invalide ou négatif", () => {
|
|
77
|
+
expect(textareaSizing(metrics, -10, 5).height).toBe(0)
|
|
78
|
+
expect(textareaSizing(metrics, Number.NaN, 5).height).toBe(0)
|
|
79
|
+
})
|
|
80
|
+
})
|
package/lib/textarea.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Auto-extension d'un textarea — calculs **purs** (aucun DOM).
|
|
2
|
+
//
|
|
3
|
+
// Le composant mesure (`getComputedStyle` + `scrollHeight`) puis délègue ici la
|
|
4
|
+
// décision de hauteur : plafond de `maxRows` lignes, défilement au-delà. Tester ces
|
|
5
|
+
// règles sans navigateur évite de dépendre d'une vraie mise en page.
|
|
6
|
+
|
|
7
|
+
/** Métriques d'un textarea, en px (résolues depuis les styles calculés). */
|
|
8
|
+
export interface TextareaMetrics {
|
|
9
|
+
fontSize: number
|
|
10
|
+
lineHeight: number
|
|
11
|
+
paddingTop: number
|
|
12
|
+
paddingBottom: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Sous-ensemble de `CSSStyleDeclaration` utile (valeurs CSS en texte). */
|
|
16
|
+
export interface TextareaComputedStyle {
|
|
17
|
+
fontSize?: string
|
|
18
|
+
lineHeight?: string
|
|
19
|
+
paddingTop?: string
|
|
20
|
+
paddingBottom?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Ratio de repli quand `line-height: normal` n'expose aucune valeur exploitable. */
|
|
24
|
+
export const DEFAULT_LINE_HEIGHT_RATIO = 1.4
|
|
25
|
+
|
|
26
|
+
const toPx = (raw: string | undefined, fallback: number): number => {
|
|
27
|
+
const n = Number.parseFloat(String(raw ?? ""))
|
|
28
|
+
return Number.isFinite(n) && n > 0 ? n : fallback
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Résout des styles calculés en métriques px (`normal` → `fontSize × 1.4`). */
|
|
32
|
+
export const textareaMetrics = (style: TextareaComputedStyle): TextareaMetrics => {
|
|
33
|
+
const fontSize = toPx(style.fontSize, 14)
|
|
34
|
+
const lineHeight = toPx(style.lineHeight, fontSize * DEFAULT_LINE_HEIGHT_RATIO)
|
|
35
|
+
return {
|
|
36
|
+
fontSize,
|
|
37
|
+
lineHeight,
|
|
38
|
+
paddingTop: toPx(style.paddingTop, 0),
|
|
39
|
+
paddingBottom: toPx(style.paddingBottom, 0),
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Hauteur maximale (px) de `maxRows` lignes — `Infinity` si `maxRows <= 0`. */
|
|
44
|
+
export const textareaCap = (metrics: TextareaMetrics, maxRows: number): number =>
|
|
45
|
+
maxRows > 0
|
|
46
|
+
? metrics.lineHeight * maxRows + metrics.paddingTop + metrics.paddingBottom
|
|
47
|
+
: Number.POSITIVE_INFINITY
|
|
48
|
+
|
|
49
|
+
export interface TextareaSizing {
|
|
50
|
+
/** Hauteur à appliquer, en px */
|
|
51
|
+
height: number
|
|
52
|
+
/** Débordement vertical à appliquer */
|
|
53
|
+
overflowY: "auto" | "hidden"
|
|
54
|
+
/** Le contenu dépasse le plafond `maxRows` : le champ défile */
|
|
55
|
+
capped: boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Hauteur à appliquer pour un `scrollHeight` mesuré, plafonnée à `maxRows` lignes. */
|
|
59
|
+
export const textareaSizing = (
|
|
60
|
+
metrics: TextareaMetrics,
|
|
61
|
+
scrollHeight: number,
|
|
62
|
+
maxRows = 0,
|
|
63
|
+
): TextareaSizing => {
|
|
64
|
+
const full = Number.isFinite(scrollHeight) ? Math.max(scrollHeight, 0) : 0
|
|
65
|
+
const cap = textareaCap(metrics, maxRows)
|
|
66
|
+
const capped = full > cap
|
|
67
|
+
return { height: Math.min(full, cap), overflowY: capped ? "auto" : "hidden", capped }
|
|
68
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// useTextareaAutosize — branchement DOM de l'auto-extension d'un <textarea>.
|
|
2
|
+
//
|
|
3
|
+
// Le calcul de hauteur est pur (`lib/textarea.ts`, testé) ; ici on ne fait que
|
|
4
|
+
// mesurer et gérer le cycle de vie : montage, redimensionnement de la fenêtre,
|
|
5
|
+
// changement de **largeur** du champ (le texte se replie → la hauteur change).
|
|
6
|
+
// Rien n'est mesuré si `enabled()` est faux : la hauteur reste celle du CSS (`rows`).
|
|
7
|
+
import { nextTick, onBeforeUnmount, onMounted, watch } from "vue"
|
|
8
|
+
import type { Ref } from "vue"
|
|
9
|
+
import { textareaMetrics, textareaSizing } from "./textarea"
|
|
10
|
+
|
|
11
|
+
export interface UseTextareaAutosizeOptions {
|
|
12
|
+
/** Auto-extension active (sinon la hauteur du CSS s'applique) */
|
|
13
|
+
enabled: () => boolean
|
|
14
|
+
/** Plafond en lignes, 0 = illimité */
|
|
15
|
+
maxRows: () => number
|
|
16
|
+
/** États dont le changement impose une re-mesure (valeur, rows, maxRows, mode…) */
|
|
17
|
+
sources?: () => unknown
|
|
18
|
+
/**
|
|
19
|
+
* Re-mesure aussi quand la **largeur** du champ change. À laisser à `false` pour
|
|
20
|
+
* un champ qui bascule `input`/`textarea` : l'élément observé y est remplacé.
|
|
21
|
+
*/
|
|
22
|
+
observeWidth?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TextareaAutosize {
|
|
26
|
+
/** Re-mesure immédiatement (après un changement programmatique de valeur) */
|
|
27
|
+
resize: () => Promise<void>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Élément mesuré : textarea, mais aussi l'`<input>` d'un champ à mode mixte. */
|
|
31
|
+
export type AutosizedElement = HTMLTextAreaElement | HTMLInputElement
|
|
32
|
+
|
|
33
|
+
export const useTextareaAutosize = <T extends AutosizedElement>(
|
|
34
|
+
el: Ref<T | null>,
|
|
35
|
+
options: UseTextareaAutosizeOptions,
|
|
36
|
+
): TextareaAutosize => {
|
|
37
|
+
const resize = async () => {
|
|
38
|
+
if (!el.value || !options.enabled()) return
|
|
39
|
+
await nextTick()
|
|
40
|
+
const node = el.value
|
|
41
|
+
if (!node || typeof getComputedStyle !== "function") return
|
|
42
|
+
// Hauteur remise à `auto` avant la mesure : sinon `scrollHeight` inclut déjà
|
|
43
|
+
// la hauteur appliquée précédemment et le champ ne rétrécit jamais.
|
|
44
|
+
node.style.height = "auto"
|
|
45
|
+
const metrics = textareaMetrics(getComputedStyle(node))
|
|
46
|
+
const sizing = textareaSizing(metrics, node.scrollHeight, options.maxRows())
|
|
47
|
+
node.style.height = `${sizing.height}px`
|
|
48
|
+
node.style.overflowY = sizing.overflowY
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (options.sources) watch(options.sources, () => void resize())
|
|
52
|
+
|
|
53
|
+
const onWindowResize = () => void resize()
|
|
54
|
+
|
|
55
|
+
let observer: ResizeObserver | null = null
|
|
56
|
+
let observedWidth = 0
|
|
57
|
+
|
|
58
|
+
onMounted(() => {
|
|
59
|
+
void resize()
|
|
60
|
+
if (typeof window === "undefined") return
|
|
61
|
+
window.addEventListener("resize", onWindowResize)
|
|
62
|
+
if (options.observeWidth === false || typeof ResizeObserver === "undefined") return
|
|
63
|
+
const node = el.value
|
|
64
|
+
if (!node) return
|
|
65
|
+
// Seule la **largeur** nous intéresse : la hauteur est la conséquence de notre
|
|
66
|
+
// propre écriture de `style.height`, l'observer rebouclerait dessus.
|
|
67
|
+
observer = new ResizeObserver((entries) => {
|
|
68
|
+
const width = entries[0]?.contentRect.width ?? 0
|
|
69
|
+
if (Math.abs(width - observedWidth) < 0.5) return
|
|
70
|
+
observedWidth = width
|
|
71
|
+
void resize()
|
|
72
|
+
})
|
|
73
|
+
observer.observe(node)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
onBeforeUnmount(() => {
|
|
77
|
+
if (typeof window !== "undefined") window.removeEventListener("resize", onWindowResize)
|
|
78
|
+
observer?.disconnect()
|
|
79
|
+
observer = null
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
return { resize }
|
|
83
|
+
}
|
package/package.json
CHANGED
package/styles/main.css
CHANGED
|
@@ -439,6 +439,7 @@ html.dark body {
|
|
|
439
439
|
}
|
|
440
440
|
|
|
441
441
|
.dark .q-input .q-field__native::placeholder,
|
|
442
|
+
.dark .q-input-textarea .q-field__native::placeholder,
|
|
442
443
|
.dark .q-input-tag__native::placeholder,
|
|
443
444
|
.dark .q-input-otp__box::placeholder,
|
|
444
445
|
.dark .q-select__search-input::placeholder,
|
|
@@ -2358,6 +2359,61 @@ html.dark body {
|
|
|
2358
2359
|
margin-left: auto;
|
|
2359
2360
|
}
|
|
2360
2361
|
|
|
2362
|
+
/* ===== QInputTextarea (champ multiligne) ===== */
|
|
2363
|
+
.q-input-textarea {
|
|
2364
|
+
display: flex;
|
|
2365
|
+
flex-direction: column;
|
|
2366
|
+
min-width: 0;
|
|
2367
|
+
font-size: 14px;
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
/* Le contenu est ancré **en haut** du contrôle (un champ simple centre sa valeur) :
|
|
2371
|
+
`rows` doit correspondre à ce qu'on voit, et les icônes / affixes restent sur la
|
|
2372
|
+
première ligne quand le champ grandit. 10px de padding = 40px de contrôle pour
|
|
2373
|
+
1 ligne de 20px, soit la hauteur exacte du champ simple. */
|
|
2374
|
+
.q-input-textarea .q-field__control {
|
|
2375
|
+
align-items: flex-start;
|
|
2376
|
+
padding: 10px 12px;
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
.q-input-textarea .q-field__native {
|
|
2380
|
+
flex: 1;
|
|
2381
|
+
width: 100%;
|
|
2382
|
+
min-width: 0;
|
|
2383
|
+
height: auto;
|
|
2384
|
+
padding: 0;
|
|
2385
|
+
border: none;
|
|
2386
|
+
outline: none;
|
|
2387
|
+
background: transparent;
|
|
2388
|
+
color: inherit;
|
|
2389
|
+
font: inherit;
|
|
2390
|
+
/* `rows` × line-height = hauteur visible ; l'auto-extension mesure la même valeur */
|
|
2391
|
+
line-height: 20px;
|
|
2392
|
+
resize: none;
|
|
2393
|
+
overflow-y: hidden;
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
.q-input-textarea .q-field__native::placeholder {
|
|
2397
|
+
color: rgb(0 0 0 / 0.45);
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
/* Affixes, icônes et bouton d'effacement : sur la première ligne */
|
|
2401
|
+
.q-input-textarea .q-field__icon,
|
|
2402
|
+
.q-input-textarea .q-field__prefix,
|
|
2403
|
+
.q-input-textarea .q-field__suffix,
|
|
2404
|
+
.q-input-textarea .q-field__clear {
|
|
2405
|
+
align-self: flex-start;
|
|
2406
|
+
line-height: 20px;
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
.q-field--dense.q-input-textarea .q-field__control {
|
|
2410
|
+
padding: 7px 10px;
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
.q-field--dense.q-input-textarea .q-field__native {
|
|
2414
|
+
line-height: 18px;
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2361
2417
|
/* ===== QInputPassword (toggle œil) ===== */
|
|
2362
2418
|
.q-input-password__toggle {
|
|
2363
2419
|
display: inline-flex;
|