feat : ajout du composant input number

This commit is contained in:
2026-03-04 13:15:43 +01:00
parent 77364daa67
commit f456ea4ddf
4 changed files with 541 additions and 0 deletions

View File

@@ -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<InputNumberProps>
const mountInputNumber = (props: InputNumberProps = {}) =>
mount(InputNumberForTest, {
props,
global: {
stubs: {
IconifyIcon: {
template: '<span data-test="icon" v-bind="$attrs" />',
},
},
},
})
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')
})
})

View File

@@ -0,0 +1,261 @@
<template>
<div :class="mergedGroupClass" >
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
</label>
<button
type="button"
:disabled="isMinusDisabled"
@click="decrement"
>
<IconifyIcon
icon="mdi:minus"
:class="mergedButtonMinusClass"
/>
</button>
<input
:id="inputId"
:name="name"
autocomplete="off"
:class="mergedInputClass"
:size="inputCharacterWidth"
:value="currentValue"
:required="required"
:disabled="disabled"
:readonly="readonly"
:aria-invalid="!!error"
:aria-describedby="describedBy"
v-bind="attrs"
type="text"
inputmode="numeric"
placeholder="_"
@input="onInput"
@focus="isFocused = true"
@blur="isFocused = false"
>
<button
type="button"
:disabled="isPlusDisabled"
@click="increment"
>
<IconifyIcon
icon="mdi:plus"
:class="mergedButtonPlusClass"
/>
</button>
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError
? 'text-m-error'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px] ',
]"
>
{{ hint || error || success }}
</p>
</template>
<script setup lang="ts">
import {computed, ref, useAttrs, useId} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
defineOptions({name: 'MalioInputNumber', inheritAttrs: false})
const props = withDefaults(
defineProps<{
id?: string
label?: string
name?: string
modelValue?: string | null | undefined
inputClass?: string
labelClass?: string
groupClass?: string
required?: boolean
min?: number | string
max?: number | string
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
}>(),
{
id: '',
name: '',
modelValue: undefined,
label: '',
inputClass: '',
labelClass: '',
groupClass: '',
required: false,
min: undefined,
max: undefined,
readonly: false,
disabled: false,
hint: '',
error: '',
success: '',
},
)
const attrs = useAttrs()
const generatedId = useId()
const localValue = ref('')
const isFocused = ref(false)
const inputId = computed(() => props.id?.toString() || `malio-input-text-${generatedId}`)
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))
const parseBound = (value: number | string | undefined) => {
if (value === undefined || value === '') return undefined
const parsedValue = Number.parseInt(String(value), 10)
return Number.isNaN(parsedValue) ? undefined : parsedValue
}
const minValue = computed(() => parseBound(props.min))
const maxValue = computed(() => parseBound(props.max))
const currentNumericValue = computed(() => {
if (currentValue.value === '') return undefined
const parsedValue = Number.parseInt(currentValue.value, 10)
return Number.isNaN(parsedValue) ? undefined : parsedValue
})
const isMinusDisabled = computed(() =>
disabled.value
|| props.readonly
|| (minValue.value !== undefined
&& currentNumericValue.value !== undefined
&& currentNumericValue.value <= minValue.value),
)
const isPlusDisabled = computed(() =>
disabled.value
|| props.readonly
|| (maxValue.value !== undefined
&& currentNumericValue.value !== undefined
&& currentNumericValue.value >= maxValue.value),
)
const mergedGroupClass = computed(() =>
twMerge(
'relative mt-4 flex h-12 w-full items-center',
props.groupClass,
),
)
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',
hasError.value
? 'border-m-error focus:border-m-error [&:not(:placeholder-shown)]:border-m-error'
: hasSuccess.value
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
: '',
props.inputClass,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'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.labelClass,
),
)
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' : '',
hasError.value
? 'border-m-error'
: hasSuccess.value
? 'border-m-success'
: '',
),
)
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' : '',
hasError.value
? 'border-m-error'
: hasSuccess.value
? 'border-m-success'
: '',
),
)
const describedBy = computed(() => {
const ids: string[] = []
if (props.hint && !hasSuccess.value && !hasError.value) ids.push(`${inputId.value}-hint`)
if (hasError.value) ids.push(`${inputId.value}-error`)
if (hasSuccess.value && !hasError.value) ids.push(`${inputId.value}-success`)
return ids.length ? ids.join(' ') : undefined
})
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
}>()
const updateValue = (value: string) => {
if (!isControlled.value) {
localValue.value = value
}
emit('update:modelValue', value)
}
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
}
const normalizeValue = (value: string) => {
const digitsOnly = value.replace(/\D+/g, '')
if (digitsOnly === '') return ''
return String(clampValue(Number.parseInt(digitsOnly, 10)))
}
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
const normalizedValue = normalizeValue(target.value)
target.value = normalizedValue
updateValue(normalizedValue)
}
const getNumericValue = () => {
const parsedValue = Number.parseInt(currentValue.value || '0', 10)
return Number.isNaN(parsedValue) ? 0 : parsedValue
}
const decrement = () => {
if (isMinusDisabled.value) return
updateValue(String(clampValue(getNumericValue() - 1)))
}
const increment = () => {
if (isPlusDisabled.value) return
updateValue(String(clampValue(getNumericValue() + 1)))
}
const disabled = computed(() => props.disabled)
</script>