feat : ajout du composant input number
This commit is contained in:
@@ -59,6 +59,46 @@ describe('MalioInputNumber', () => {
|
|||||||
expect(input.element.value).toBe('123')
|
expect(input.element.value).toBe('123')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('formats large numbers with spaces in the input display', async () => {
|
||||||
|
const wrapper = mountInputNumber({modelValue: ''})
|
||||||
|
const input = wrapper.get('input')
|
||||||
|
|
||||||
|
await input.setValue('1000000')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['1000000'])
|
||||||
|
expect(input.element.value).toBe('1 000 000')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts decimal values with commas', async () => {
|
||||||
|
const wrapper = mountInputNumber({modelValue: ''})
|
||||||
|
const input = wrapper.get('input')
|
||||||
|
|
||||||
|
await input.setValue('12,5')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['12.5'])
|
||||||
|
expect(input.element.value).toBe('12.5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a trailing decimal separator while typing', async () => {
|
||||||
|
const wrapper = mountInputNumber({modelValue: ''})
|
||||||
|
const input = wrapper.get('input')
|
||||||
|
|
||||||
|
await input.setValue('12,')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['12.'])
|
||||||
|
expect(input.element.value).toBe('12.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts a decimal starting with a comma', async () => {
|
||||||
|
const wrapper = mountInputNumber({modelValue: ''})
|
||||||
|
const input = wrapper.get('input')
|
||||||
|
|
||||||
|
await input.setValue(',5')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['0.5'])
|
||||||
|
expect(input.element.value).toBe('0.5')
|
||||||
|
})
|
||||||
|
|
||||||
it('increments the current value when clicking plus', async () => {
|
it('increments the current value when clicking plus', async () => {
|
||||||
const wrapper = mountInputNumber({modelValue: '2'})
|
const wrapper = mountInputNumber({modelValue: '2'})
|
||||||
|
|
||||||
@@ -67,6 +107,14 @@ describe('MalioInputNumber', () => {
|
|||||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['3'])
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['3'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('increments decimal values with a step of 1', async () => {
|
||||||
|
const wrapper = mountInputNumber({modelValue: '1.5'})
|
||||||
|
|
||||||
|
await wrapper.findAll('button')[1].trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['2.5'])
|
||||||
|
})
|
||||||
|
|
||||||
it('decrements the current value when clicking minus', async () => {
|
it('decrements the current value when clicking minus', async () => {
|
||||||
const wrapper = mountInputNumber({modelValue: '2'})
|
const wrapper = mountInputNumber({modelValue: '2'})
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,8 @@
|
|||||||
:name="name"
|
:name="name"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
:class="mergedInputClass"
|
:class="mergedInputClass"
|
||||||
:size="inputCharacterWidth"
|
:style="inputWidthStyle"
|
||||||
:value="currentValue"
|
:value="displayedValue"
|
||||||
:required="required"
|
:required="required"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
@@ -119,33 +119,53 @@ const isControlled = computed(() => props.modelValue !== undefined)
|
|||||||
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
|
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
|
||||||
const hasError = computed(() => !!props.error)
|
const hasError = computed(() => !!props.error)
|
||||||
const hasSuccess = computed(() => !!props.success)
|
const hasSuccess = computed(() => !!props.success)
|
||||||
const inputCharacterWidth = computed(() => Math.max(currentValue.value.length, 1))
|
|
||||||
|
// Ajoute un separateur de milliers pour l'affichage dans le champ.
|
||||||
|
const formatDisplayValue = (value: string) => {
|
||||||
|
if (!value) return ''
|
||||||
|
const [integerPart = '', decimalPart] = value.split('.')
|
||||||
|
const formattedIntegerPart = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
|
||||||
|
|
||||||
|
if (decimalPart !== undefined) {
|
||||||
|
return `${formattedIntegerPart}.${decimalPart}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return formattedIntegerPart
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valeur visible dans l'input, avec formatage des milliers.
|
||||||
|
const displayedValue = computed(() => formatDisplayValue(currentValue.value))
|
||||||
|
const inputCharacterWidth = computed(() => Math.max(displayedValue.value.length, 1))
|
||||||
|
|
||||||
|
// Transforme min/max en nombres utilisables.
|
||||||
const parseBound = (value: number | string | undefined) => {
|
const parseBound = (value: number | string | undefined) => {
|
||||||
if (value === undefined || value === '') return undefined
|
if (value === undefined || value === '') return undefined
|
||||||
const parsedValue = Number.parseInt(String(value), 10)
|
const parsedValue = Number.parseFloat(String(value).replace(',', '.'))
|
||||||
return Number.isNaN(parsedValue) ? undefined : parsedValue
|
return Number.isNaN(parsedValue) ? undefined : parsedValue
|
||||||
}
|
}
|
||||||
|
|
||||||
const minValue = computed(() => parseBound(props.min))
|
const minValue = computed(() => parseBound(props.min))
|
||||||
const maxValue = computed(() => parseBound(props.max))
|
const maxValue = computed(() => parseBound(props.max))
|
||||||
|
|
||||||
|
// Recupere la valeur numerique brute actuellement saisie.
|
||||||
const currentNumericValue = computed(() => {
|
const currentNumericValue = computed(() => {
|
||||||
if (currentValue.value === '') return undefined
|
if (currentValue.value === '') return undefined
|
||||||
const parsedValue = Number.parseInt(currentValue.value, 10)
|
const parsedValue = Number.parseFloat(currentValue.value)
|
||||||
return Number.isNaN(parsedValue) ? undefined : parsedValue
|
return Number.isNaN(parsedValue) ? undefined : parsedValue
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const inputWidthStyle = computed(() => ({
|
||||||
|
width: `calc(${inputCharacterWidth.value}ch + 30px)`,
|
||||||
|
maxWidth: '100%',
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
const isMinusDisabled = computed(() =>
|
const isMinusDisabled = computed(() =>
|
||||||
disabled.value
|
props.disabled || currentNumericValue.value <= minValue.value,
|
||||||
|| props.readonly
|
|
||||||
|| (minValue.value !== undefined
|
|
||||||
&& currentNumericValue.value !== undefined
|
|
||||||
&& currentNumericValue.value <= minValue.value),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const isPlusDisabled = computed(() =>
|
const isPlusDisabled = computed(() =>
|
||||||
disabled.value
|
props.disabled || currentNumericValue.value >= maxValue.value,
|
||||||
|| props.readonly
|
|
||||||
|| (maxValue.value !== undefined
|
|
||||||
&& currentNumericValue.value !== undefined
|
|
||||||
&& currentNumericValue.value >= maxValue.value),
|
|
||||||
|
|
||||||
)
|
)
|
||||||
const mergedGroupClass = computed(() =>
|
const mergedGroupClass = computed(() =>
|
||||||
@@ -156,8 +176,8 @@ const mergedGroupClass = computed(() =>
|
|||||||
)
|
)
|
||||||
const mergedInputClass = computed(() =>
|
const mergedInputClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
' peer h-[20px] border bg-white text-center outline-none placeholder:text-transparent text-lg border-x-0 border-black',
|
' peer h-[20px] min-w-0 border bg-white text-center outline-none placeholder:text-transparent text-lg border-x-0 border-black',
|
||||||
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
|
props.disabled ? 'cursor-not-allowed text-black/60' : 'cursor-text',
|
||||||
hasError.value
|
hasError.value
|
||||||
? 'border-m-error focus:border-m-error [&:not(:placeholder-shown)]:border-m-error'
|
? 'border-m-error focus:border-m-error [&:not(:placeholder-shown)]:border-m-error'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
@@ -171,7 +191,7 @@ const mergedLabelClass = computed(() =>
|
|||||||
'radio-text mt-px cursor-pointer text-black mr-3',
|
'radio-text mt-px cursor-pointer text-black mr-3',
|
||||||
hasError.value ? 'text-m-error' : '',
|
hasError.value ? 'text-m-error' : '',
|
||||||
hasSuccess.value ? 'text-m-success' : '',
|
hasSuccess.value ? 'text-m-success' : '',
|
||||||
disabled.value ? 'cursor-not-allowed text-black/60' : '',
|
props.disabled ? 'cursor-not-allowed text-black/60' : '',
|
||||||
props.labelClass,
|
props.labelClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -179,8 +199,7 @@ const mergedLabelClass = computed(() =>
|
|||||||
const mergedButtonMinusClass = computed(() =>
|
const mergedButtonMinusClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'h-[20px] w-[30px] border border-black rounded-s-[3px]',
|
'h-[20px] w-[30px] border border-black rounded-s-[3px]',
|
||||||
props.readonly ? 'cursor-default' : 'cursor-pointer hover:bg-m-muted/10',
|
isMinusDisabled.value ? 'cursor-not-allowed text-black/60' : 'cursor-pointer',
|
||||||
disabled.value ? 'cursor-not-allowed text-black/60 border-m-muted' : '',
|
|
||||||
hasError.value
|
hasError.value
|
||||||
? 'border-m-error'
|
? 'border-m-error'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
@@ -191,8 +210,7 @@ const mergedButtonMinusClass = computed(() =>
|
|||||||
const mergedButtonPlusClass = computed(() =>
|
const mergedButtonPlusClass = computed(() =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'h-[20px] w-[30px] border border-black rounded-e-[3px]',
|
'h-[20px] w-[30px] border border-black rounded-e-[3px]',
|
||||||
props.readonly ? 'cursor-default' : 'cursor-pointer hover:bg-m-muted/10',
|
isPlusDisabled.value ? 'cursor-not-allowed text-black/60' : 'cursor-pointer',
|
||||||
disabled.value ? 'cursor-not-allowed text-black/60 border-m-muted' : '',
|
|
||||||
hasError.value
|
hasError.value
|
||||||
? 'border-m-error'
|
? 'border-m-error'
|
||||||
: hasSuccess.value
|
: hasSuccess.value
|
||||||
@@ -213,6 +231,7 @@ const emit = defineEmits<{
|
|||||||
(event: 'update:modelValue', value: string): void
|
(event: 'update:modelValue', value: string): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// Met a jour l'etat local si besoin puis emet la valeur brute.
|
||||||
const updateValue = (value: string) => {
|
const updateValue = (value: string) => {
|
||||||
if (!isControlled.value) {
|
if (!isControlled.value) {
|
||||||
localValue.value = value
|
localValue.value = value
|
||||||
@@ -220,42 +239,62 @@ const updateValue = (value: string) => {
|
|||||||
emit('update:modelValue', value)
|
emit('update:modelValue', value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Force la valeur a rester entre les bornes min et max.
|
||||||
const clampValue = (value: number) => {
|
const clampValue = (value: number) => {
|
||||||
if (minValue.value !== undefined && value < minValue.value) return minValue.value
|
if (minValue.value !== undefined && value < minValue.value) return minValue.value
|
||||||
if (maxValue.value !== undefined && value > maxValue.value) return maxValue.value
|
if (maxValue.value !== undefined && value > maxValue.value) return maxValue.value
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Garde uniquement les chiffres et la virgule puis applique les bornes.
|
||||||
const normalizeValue = (value: string) => {
|
const normalizeValue = (value: string) => {
|
||||||
const digitsOnly = value.replace(/\D+/g, '')
|
const sanitizedValue = value
|
||||||
|
.replace(/[^\d,.]/g, '')
|
||||||
|
.replace(/,/g, '.')
|
||||||
|
|
||||||
if (digitsOnly === '') return ''
|
const [integerPart = '', ...decimalParts] = sanitizedValue.split('.')
|
||||||
|
const decimalPart = decimalParts.join('')
|
||||||
|
const hasDecimalSeparator = sanitizedValue.includes('.')
|
||||||
|
|
||||||
return String(clampValue(Number.parseInt(digitsOnly, 10)))
|
if (hasDecimalSeparator) {
|
||||||
|
const normalizedValue = `${integerPart || '0'}.${decimalPart}`
|
||||||
|
const parsedValue = Number.parseFloat(normalizedValue)
|
||||||
|
|
||||||
|
if (Number.isNaN(parsedValue)) return ''
|
||||||
|
|
||||||
|
const clampedValue = clampValue(parsedValue)
|
||||||
|
if (clampedValue !== parsedValue) return String(clampedValue)
|
||||||
|
|
||||||
|
return decimalPart === '' ? `${integerPart || '0'}.` : normalizedValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return integerPart === '' ? '' : String(clampValue(Number.parseFloat(integerPart)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reformate l'affichage dans le champ tout en conservant une valeur brute pour le v-model.
|
||||||
const onInput = (event: Event) => {
|
const onInput = (event: Event) => {
|
||||||
const target = event.target as HTMLInputElement
|
const target = event.target as HTMLInputElement
|
||||||
const normalizedValue = normalizeValue(target.value)
|
const normalizedValue = normalizeValue(target.value)
|
||||||
|
|
||||||
target.value = normalizedValue
|
target.value = formatDisplayValue(normalizedValue)
|
||||||
updateValue(normalizedValue)
|
updateValue(normalizedValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retourne la valeur numerique courante, ou 0 si le champ est vide.
|
||||||
const getNumericValue = () => {
|
const getNumericValue = () => {
|
||||||
const parsedValue = Number.parseInt(currentValue.value || '0', 10)
|
const parsedValue = Number.parseFloat(currentValue.value || '0')
|
||||||
return Number.isNaN(parsedValue) ? 0 : parsedValue
|
return Number.isNaN(parsedValue) ? 0 : parsedValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retire 1 a la valeur si l'action est autorisee.
|
||||||
const decrement = () => {
|
const decrement = () => {
|
||||||
if (isMinusDisabled.value) return
|
if (props.disabled || props.readonly || isMinusDisabled.value) return
|
||||||
updateValue(String(clampValue(getNumericValue() - 1)))
|
updateValue(String(clampValue(getNumericValue() - 1)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ajoute 1 a la valeur si l'action est autorisee.
|
||||||
const increment = () => {
|
const increment = () => {
|
||||||
if (isPlusDisabled.value) return
|
if (props.disabled || props.readonly || isPlusDisabled.value) return
|
||||||
updateValue(String(clampValue(getNumericValue() + 1)))
|
updateValue(String(clampValue(getNumericValue() + 1)))
|
||||||
}
|
}
|
||||||
|
|
||||||
const disabled = computed(() => props.disabled)
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user