From 1290fe38ccd11707a2dce24454f4888dff3fc8f3 Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 9 Jun 2026 13:51:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(amount)=20:=20affichage=20group=C3=A9=20te?= =?UTF-8?q?mps=20r=C3=A9el=20(s=C3=A9parateurs=20de=20milliers)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/components/malio/input/InputAmount.vue | 55 ++++++++++------------ 1 file changed, 26 insertions(+), 29 deletions(-) diff --git a/app/components/malio/input/InputAmount.vue b/app/components/malio/input/InputAmount.vue index 1993972..d8806ae 100644 --- a/app/components/malio/input/InputAmount.vue +++ b/app/components/malio/input/InputAmount.vue @@ -9,10 +9,9 @@ :autocomplete="autocomplete" :class="mergedInputClass" :required="required" - :maxlength="maxLength" :minlength="minLength" :disabled="disabled" - :value="currentValue" + :value="formattedValue" :readonly="readonly" :aria-invalid="!!error" :aria-describedby="describedBy" @@ -66,6 +65,7 @@ import {computed, ref, useAttrs, useId} from 'vue' import { Icon as IconifyIcon } from '@iconify/vue' import {twMerge} from 'tailwind-merge' import MalioRequiredMark from '../shared/RequiredMark.vue' +import {normalizeAmount, formatGroupedAmount, countSignificant, caretFromSignificant} from './composables/amountFormat' defineOptions({name: 'MalioInputAmount', inheritAttrs: false}) @@ -126,6 +126,7 @@ const isFocused = ref(false) const inputId = computed(() => props.id?.toString() || `malio-input-amount-${generatedId}`) const isControlled = computed(() => props.modelValue !== undefined) const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value)) +const formattedValue = computed(() => formatGroupedAmount(currentValue.value)) const hasError = computed(() => !!props.error) const hasSuccess = computed(() => !!props.success) const isFilled = computed(() => currentValue.value.trim().length > 0) @@ -190,35 +191,31 @@ const emit = defineEmits<{ (event: 'update:modelValue', value: string): void }>() -const normalizeAmount = (value: string) => { - const sanitizedValue = value - .replace(/\s+/g, '') - .replace(/,/g, '.') - .replace(/[^\d.]/g, '') - const [integerPartRaw = '', ...decimalParts] = sanitizedValue.split('.') - const integerPart = integerPartRaw.replace(/^0+(?=\d)/, '') - const decimalPart = decimalParts.join('').slice(0, 2) - - if (sanitizedValue.includes('.')) { - return `${integerPart || '0'}.${decimalPart}` - } - - return integerPart -} - -// Keep the DOM input value, local state, and v-model emission in sync. -const updateValue = (target: HTMLInputElement, value: string) => { - target.value = value - if (!isControlled.value) { - localValue.value = value - } - emit('update:modelValue', value) -} - -// Normalize while typing so the field never keeps invalid amount characters. +// À la frappe : parse vers le modèle propre (émis), reformate l'affichage groupé, repositionne le curseur. const onInput = (event: Event) => { const target = event.target as HTMLInputElement - updateValue(target, normalizeAmount(target.value)) + const rawText = target.value + const caret = target.selectionStart ?? rawText.length + const model = normalizeAmount(rawText) + + // maxLength borne la longueur du MODÈLE (pas l'affichage) : on ignore le keystroke en dépassement. + if (props.maxLength != null && model.length > Number(props.maxLength)) { + target.value = formattedValue.value + const restored = Math.max(0, caret - 1) + target.setSelectionRange(restored, restored) + return + } + + const display = formatGroupedAmount(model) + const sig = countSignificant(rawText, caret) + target.value = display + const newCaret = caretFromSignificant(display, sig) + target.setSelectionRange(newCaret, newCaret) + + if (!isControlled.value) { + localValue.value = model + } + emit('update:modelValue', model) } // Keep the blur handler only for focus-driven UI state.