@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.
@@ -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
- import { computed, nextTick, ref, watch } from "vue"
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 (!props.mask) return v
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
- const resizeTextarea = async () => {
176
- const el = nativeEl.value
177
- if (el && props.autogrow) {
178
- await nextTick()
179
- el.style.height = "auto"
180
- el.style.height = `${el.scrollHeight}px`
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
- watch(value, resizeTextarea)
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 (!props.mask) {
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 inputAttrs = computed(() => ({
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="autogrow"
277
+ v-if="isTextarea"
240
278
  ref="nativeEl"
241
279
  class="q-field__native"
242
280
  :value="displayValue"
243
- rows="1"
244
- v-bind="inputAttrs"
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
+ })
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dnax/ui",
3
- "version": "0.0.72",
3
+ "version": "0.0.73",
4
4
  "module": "index.ts",
5
5
  "description": "UI design System",
6
6
  "type": "module",
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;