From f456ea4ddf982c4fc933fc3af81bc00a8dfe5430 Mon Sep 17 00:00:00 2001 From: kevin Date: Wed, 4 Mar 2026 13:15:43 +0100 Subject: [PATCH 1/2] feat : ajout du composant input number --- .playground/pages/composant/inputNumber.vue | 80 ++++++ app/components/malio/InputNumber.test.ts | 117 +++++++++ app/components/malio/InputNumber.vue | 261 ++++++++++++++++++++ app/story/inputNumber.story.vue | 83 +++++++ 4 files changed, 541 insertions(+) create mode 100644 .playground/pages/composant/inputNumber.vue create mode 100644 app/components/malio/InputNumber.test.ts create mode 100644 app/components/malio/InputNumber.vue create mode 100644 app/story/inputNumber.story.vue diff --git a/.playground/pages/composant/inputNumber.vue b/.playground/pages/composant/inputNumber.vue new file mode 100644 index 0000000..7313ebf --- /dev/null +++ b/.playground/pages/composant/inputNumber.vue @@ -0,0 +1,80 @@ + + + diff --git a/app/components/malio/InputNumber.test.ts b/app/components/malio/InputNumber.test.ts new file mode 100644 index 0000000..943b0bd --- /dev/null +++ b/app/components/malio/InputNumber.test.ts @@ -0,0 +1,117 @@ +import {describe, expect, it} from 'vitest' +import {mount} from '@vue/test-utils' +import type {DefineComponent} from 'vue' +import InputNumber from './InputNumber.vue' + +type InputNumberProps = { + modelValue?: string | null + label?: string + readonly?: boolean + min?: number | string + max?: number | string +} + +const InputNumberForTest = InputNumber as DefineComponent + +const mountInputNumber = (props: InputNumberProps = {}) => + mount(InputNumberForTest, { + props, + global: { + stubs: { + IconifyIcon: { + template: '', + }, + }, + }, + }) + +describe('MalioInputNumber', () => { + it('renders the input with a fixed 20px height', () => { + const wrapper = mountInputNumber() + const input = wrapper.get('input') + + expect(input.classes()).toContain('h-[20px]') + }) + + it('renders the increment and decrement buttons with a fixed 20px height', () => { + const wrapper = mountInputNumber() + const buttons = wrapper.findAll('button') + + expect(buttons).toHaveLength(2) + }) + + it('still emits update:modelValue on input', async () => { + const wrapper = mountInputNumber({modelValue: ''}) + const input = wrapper.get('input') + + await input.setValue('99') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['99']) + }) + + it('filters letters from the input value', async () => { + const wrapper = mountInputNumber({modelValue: ''}) + const input = wrapper.get('input') + + await input.setValue('a1b2c3') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['123']) + expect(input.element.value).toBe('123') + }) + + it('increments the current value when clicking plus', async () => { + const wrapper = mountInputNumber({modelValue: '2'}) + + await wrapper.findAll('button')[1].trigger('click') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['3']) + }) + + it('decrements the current value when clicking minus', async () => { + const wrapper = mountInputNumber({modelValue: '2'}) + + await wrapper.findAll('button')[0].trigger('click') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['1']) + }) + + it('does not change the value from buttons when readonly', async () => { + const wrapper = mountInputNumber({modelValue: '2', readonly: true}) + + await wrapper.findAll('button')[1].trigger('click') + + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('disables minus and prevents decrement at min', async () => { + const wrapper = mountInputNumber({modelValue: '2', min: 2}) + const minusButton = wrapper.findAll('button')[0] + + expect(minusButton.attributes('disabled')).toBeDefined() + + await minusButton.trigger('click') + + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('disables plus and prevents increment at max', async () => { + const wrapper = mountInputNumber({modelValue: '2', max: 2}) + const plusButton = wrapper.findAll('button')[1] + + expect(plusButton.attributes('disabled')).toBeDefined() + + await plusButton.trigger('click') + + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('clamps manual input to max', async () => { + const wrapper = mountInputNumber({modelValue: '', max: 5}) + const input = wrapper.get('input') + + await input.setValue('12') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['5']) + expect(input.element.value).toBe('5') + }) +}) diff --git a/app/components/malio/InputNumber.vue b/app/components/malio/InputNumber.vue new file mode 100644 index 0000000..33c83cf --- /dev/null +++ b/app/components/malio/InputNumber.vue @@ -0,0 +1,261 @@ + + + diff --git a/app/story/inputNumber.story.vue b/app/story/inputNumber.story.vue new file mode 100644 index 0000000..8b5dbaf --- /dev/null +++ b/app/story/inputNumber.story.vue @@ -0,0 +1,83 @@ + + + From cc04114f898bb06ac08c55f3e5ef166f1a5dd7e0 Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 5 Mar 2026 09:38:56 +0100 Subject: [PATCH 2/2] feat : ajout du composant input number --- app/components/malio/InputNumber.test.ts | 48 +++++++++++ app/components/malio/InputNumber.vue | 101 ++++++++++++++++------- 2 files changed, 118 insertions(+), 31 deletions(-) diff --git a/app/components/malio/InputNumber.test.ts b/app/components/malio/InputNumber.test.ts index 943b0bd..009e065 100644 --- a/app/components/malio/InputNumber.test.ts +++ b/app/components/malio/InputNumber.test.ts @@ -59,6 +59,46 @@ describe('MalioInputNumber', () => { 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 () => { const wrapper = mountInputNumber({modelValue: '2'}) @@ -67,6 +107,14 @@ describe('MalioInputNumber', () => { 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 () => { const wrapper = mountInputNumber({modelValue: '2'}) diff --git a/app/components/malio/InputNumber.vue b/app/components/malio/InputNumber.vue index 33c83cf..c995da4 100644 --- a/app/components/malio/InputNumber.vue +++ b/app/components/malio/InputNumber.vue @@ -22,8 +22,8 @@ :name="name" autocomplete="off" :class="mergedInputClass" - :size="inputCharacterWidth" - :value="currentValue" + :style="inputWidthStyle" + :value="displayedValue" :required="required" :disabled="disabled" :readonly="readonly" @@ -119,33 +119,53 @@ const isControlled = computed(() => props.modelValue !== undefined) const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value)) const hasError = computed(() => !!props.error) 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) => { 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 } const minValue = computed(() => parseBound(props.min)) const maxValue = computed(() => parseBound(props.max)) + +// Recupere la valeur numerique brute actuellement saisie. const currentNumericValue = computed(() => { if (currentValue.value === '') return undefined - const parsedValue = Number.parseInt(currentValue.value, 10) + const parsedValue = Number.parseFloat(currentValue.value) return Number.isNaN(parsedValue) ? undefined : parsedValue }) + +const inputWidthStyle = computed(() => ({ + width: `calc(${inputCharacterWidth.value}ch + 30px)`, + maxWidth: '100%', +})) + + const isMinusDisabled = computed(() => - disabled.value - || props.readonly - || (minValue.value !== undefined - && currentNumericValue.value !== undefined - && currentNumericValue.value <= minValue.value), + props.disabled || currentNumericValue.value <= minValue.value, ) + const isPlusDisabled = computed(() => - disabled.value - || props.readonly - || (maxValue.value !== undefined - && currentNumericValue.value !== undefined - && currentNumericValue.value >= maxValue.value), + props.disabled || currentNumericValue.value >= maxValue.value, ) const mergedGroupClass = computed(() => @@ -156,8 +176,8 @@ const mergedGroupClass = computed(() => ) const mergedInputClass = computed(() => twMerge( - ' peer h-[20px] 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', + ' peer h-[20px] min-w-0 border bg-white text-center outline-none placeholder:text-transparent text-lg border-x-0 border-black', + props.disabled ? 'cursor-not-allowed text-black/60' : 'cursor-text', hasError.value ? 'border-m-error focus:border-m-error [&:not(:placeholder-shown)]:border-m-error' : hasSuccess.value @@ -171,7 +191,7 @@ const mergedLabelClass = computed(() => 'radio-text mt-px cursor-pointer text-black mr-3', hasError.value ? 'text-m-error' : '', hasSuccess.value ? 'text-m-success' : '', - disabled.value ? 'cursor-not-allowed text-black/60' : '', + props.disabled ? 'cursor-not-allowed text-black/60' : '', props.labelClass, ), ) @@ -179,8 +199,7 @@ const mergedLabelClass = computed(() => const mergedButtonMinusClass = computed(() => twMerge( 'h-[20px] w-[30px] border border-black rounded-s-[3px]', - props.readonly ? 'cursor-default' : 'cursor-pointer hover:bg-m-muted/10', - disabled.value ? 'cursor-not-allowed text-black/60 border-m-muted' : '', + isMinusDisabled.value ? 'cursor-not-allowed text-black/60' : 'cursor-pointer', hasError.value ? 'border-m-error' : hasSuccess.value @@ -191,8 +210,7 @@ const mergedButtonMinusClass = computed(() => const mergedButtonPlusClass = computed(() => twMerge( 'h-[20px] w-[30px] border border-black rounded-e-[3px]', - props.readonly ? 'cursor-default' : 'cursor-pointer hover:bg-m-muted/10', - disabled.value ? 'cursor-not-allowed text-black/60 border-m-muted' : '', + isPlusDisabled.value ? 'cursor-not-allowed text-black/60' : 'cursor-pointer', hasError.value ? 'border-m-error' : hasSuccess.value @@ -213,6 +231,7 @@ const emit = defineEmits<{ (event: 'update:modelValue', value: string): void }>() +// Met a jour l'etat local si besoin puis emet la valeur brute. const updateValue = (value: string) => { if (!isControlled.value) { localValue.value = value @@ -220,42 +239,62 @@ const updateValue = (value: string) => { emit('update:modelValue', value) } +// Force la valeur a rester entre les bornes min et max. const clampValue = (value: number) => { if (minValue.value !== undefined && value < minValue.value) return minValue.value if (maxValue.value !== undefined && value > maxValue.value) return maxValue.value return value } +// Garde uniquement les chiffres et la virgule puis applique les bornes. 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 target = event.target as HTMLInputElement const normalizedValue = normalizeValue(target.value) - target.value = normalizedValue + target.value = formatDisplayValue(normalizedValue) updateValue(normalizedValue) } +// Retourne la valeur numerique courante, ou 0 si le champ est vide. const getNumericValue = () => { - const parsedValue = Number.parseInt(currentValue.value || '0', 10) + const parsedValue = Number.parseFloat(currentValue.value || '0') return Number.isNaN(parsedValue) ? 0 : parsedValue } +// Retire 1 a la valeur si l'action est autorisee. const decrement = () => { - if (isMinusDisabled.value) return + if (props.disabled || props.readonly || isMinusDisabled.value) return updateValue(String(clampValue(getNumericValue() - 1))) } +// Ajoute 1 a la valeur si l'action est autorisee. const increment = () => { - if (isPlusDisabled.value) return + if (props.disabled || props.readonly || isPlusDisabled.value) return updateValue(String(clampValue(getNumericValue() + 1))) } - -const disabled = computed(() => props.disabled)