[#362] Création d'un composant de type Montant #4

Merged
tristan merged 3 commits from feat/creation-composant-montant into develop 2026-03-03 10:42:40 +00:00
4 changed files with 678 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
<template>
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2>
<MalioInputAmount />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec label</h2>
<MalioInputAmount
label="Montant"
name="amount"
autocomplete="off"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioInputAmount
model-value="125.00"
disabled
label="Montant désactivé"
/>
<MalioInputAmount
disabled
label="Montant désactivé"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly</h2>
<MalioInputAmount
model-value="42.50"
readonly
label="Montant readonly"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur et succès</h2>
<div class="mt-4">
<MalioInputAmount
model-value="12.3"
label="Montant"
error="Le montant est incorrect"
/>
</div>
<div class="mt-4">
<MalioInputAmount
model-value="89.90"
label="Montant"
success="Montant valide"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
</script>

View File

@@ -0,0 +1,163 @@
import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import InputAmount from './InputAmount.vue'
type InputAmountProps = {
id?: string
label?: string
name?: string
autocomplete?: string
modelValue?: string | null
inputClass?: string
labelClass?: string
groupClass?: string
required?: boolean
maxLength?: number | string
minLength?: number | string
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
iconName?: string
iconPosition?: 'left' | 'right'
iconSize?: string | number
iconColor?: string
}
const InputAmountForTest = InputAmount as DefineComponent<InputAmountProps>
const mountInputAmount = (props: InputAmountProps = {}) =>
mount(InputAmountForTest, {
props,
global: {
stubs: {
IconifyIcon: {
template: '<span data-test="icon" v-bind="$attrs" />',
},
},
},
})
describe('MalioInputAmount', () => {
it('renders as a text input with decimal input mode', () => {
const wrapper = mountInputAmount()
expect(wrapper.get('input').attributes('type')).toBe('text')
expect(wrapper.get('input').attributes('inputmode')).toBe('decimal')
})
it('renders the default icon with muted styling', () => {
const wrapper = mountInputAmount()
expect(wrapper.get('[data-test="icon"]').exists()).toBe(true)
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('right-2')
})
it('generates an amount-specific id', () => {
const wrapper = mountInputAmount({label: 'Montant'})
const inputId = wrapper.get('input').attributes('id')
expect(inputId?.startsWith('malio-input-amount-')).toBe(true)
expect(wrapper.get('label').attributes('for')).toBe(inputId)
})
it('applies the provided input classes', () => {
const wrapper = mountInputAmount({inputClass: 'text-right'})
expect(wrapper.get('input').classes()).toContain('text-right')
})
it('links hint text through aria-describedby', () => {
const wrapper = mountInputAmount({hint: 'Saisissez un montant'})
const inputId = wrapper.get('input').attributes('id')
expect(wrapper.get('input').attributes('aria-describedby')).toBe(`${inputId}-describedby`)
expect(wrapper.get('p').attributes('id')).toBe(`${inputId}-describedby`)
})
it('sets aria-invalid and describedby when showing an error', () => {
const wrapper = mountInputAmount({error: 'Montant invalide'})
const inputId = wrapper.get('input').attributes('id')
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
expect(wrapper.get('input').attributes('aria-describedby')).toBe(`${inputId}-describedby`)
expect(wrapper.get('p.text-m-error').text()).toBe('Montant invalide')
})
it('keeps dots as the decimal separator on input', async () => {
const wrapper = mountInputAmount({modelValue: ''})
await wrapper.get('input').setValue('12.5')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['12.5'])
expect(wrapper.get('input').element.value).toBe('12.5')
})
it('accepts commas but normalizes them to dots', async () => {
const wrapper = mountInputAmount({modelValue: ''})
await wrapper.get('input').setValue('0012,345abc')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['12.34'])
expect(wrapper.get('input').element.value).toBe('12.34')
})
it('normalizes a leading decimal separator', async () => {
const wrapper = mountInputAmount({modelValue: ''})
await wrapper.get('input').setValue(',5')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['0.5'])
expect(wrapper.get('input').element.value).toBe('0.5')
})
it('keeps the normalized decimal value on blur', async () => {
const wrapper = mountInputAmount()
const input = wrapper.get('input')
await input.setValue('12.5')
await input.trigger('blur')
expect(wrapper.emitted('update:modelValue')).toEqual([['12.5']])
expect(input.element.value).toBe('12.5')
})
it('keeps integer values unchanged on blur', async () => {
const wrapper = mountInputAmount()
const input = wrapper.get('input')
await input.setValue('12')
await input.trigger('blur')
expect(wrapper.emitted('update:modelValue')).toEqual([['12']])
expect(input.element.value).toBe('12')
})
it('keeps an empty value empty on blur', async () => {
const wrapper = mountInputAmount()
const input = wrapper.get('input')
await input.setValue('')
await input.trigger('blur')
expect(wrapper.emitted('update:modelValue')).toEqual([['']])
expect(input.element.value).toBe('')
})
it('supports icon positioning on the left', () => {
const wrapper = mountInputAmount({
label: 'Montant',
iconPosition: 'left',
})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('left-2')
expect(wrapper.get('input').classes()).toContain('!pl-11')
expect(wrapper.get('label').classes()).toContain('left-8')
})
})

View File

@@ -0,0 +1,255 @@
<template>
<div
:class="mergedGroupClass"
>
<input
:id="inputId"
:name="name"
:autocomplete="autocomplete"
:class="mergedInputClass"
:required="required"
:maxlength="maxLength"
:minlength="minLength"
:disabled="disabled"
:value="currentValue"
:readonly="readonly"
:aria-invalid="!!error"
:aria-describedby="describedBy"
v-bind="attrs"
type="text"
inputmode="decimal"
placeholder="_"
@input="onInput"
@focus="isFocused = true"
@blur="onBlur"
>
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
</label>
<IconifyIcon
v-if="iconName"
:icon="iconName"
:width="iconSize"
:height="iconSize"
data-test="icon"
:class="[
hasError
? 'text-m-error'
: hasSuccess
? 'text-m-success' : iconColor,
iconPositionClass,
]"
/>
</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: 'MalioInputAmount', inheritAttrs: false})
const props = withDefaults(
defineProps<{
id?: string
label?: string
name?: string
autocomplete?: string
modelValue?: string | null | undefined
inputClass?: string
labelClass?: string
groupClass?: string
required?: boolean
maxLength?: number | string
minLength?: number | string
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
iconName?: string
iconPosition?: 'left' | 'right'
iconSize?: string | number
iconColor?: string
}>(),
{
id: '',
name: '',
autocomplete: 'off',
modelValue: undefined,
iconName: 'mdi:currency-eur',
iconPosition: 'right',
label: '',
inputClass: '',
labelClass: '',
groupClass: '',
required: false,
maxLength: undefined,
minLength: undefined,
readonly: false,
disabled: false,
hint: '',
error: '',
success: '',
iconSize: 24,
iconColor: 'text-m-muted',
},
)
const attrs = useAttrs()
const generatedId = useId()
const localValue = ref('')
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 shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success)
const isFilled = computed(() => currentValue.value.trim().length > 0)
const mergedGroupClass = computed(() =>
twMerge(
'relative mt-4 flex h-12 w-full items-center',
props.groupClass,
),
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent focus:border-2 text-lg rounded-md',
isFilled.value ? 'border-black' : 'border-m-muted',
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'
: 'focus:border-m-primary',
props.inputClass,
iconInputPaddingClass.value,
focusPaddingClass.value,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value,
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
hasError.value
? 'text-m-error'
: hasSuccess.value
? 'text-m-success'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
const describedBy = computed(() => {
if (!props.hint && !hasError.value && !hasSuccess.value) return undefined
return `${inputId.value}-describedby`
})
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.
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
updateValue(target, normalizeAmount(target.value))
}
// Keep the blur handler only for focus-driven UI state.
const onBlur = () => {
isFocused.value = false
}
const iconInputPaddingClass = computed(() => {
if (!props.iconName) return ''
return props.iconPosition === 'left' ? '!pl-11 !pr-3' : ''
})
const disabled = computed(() => props.disabled)
const labelPositionClass = computed(() => {
if (props.iconName && props.iconPosition === 'left') return 'left-8'
return 'left-3'
})
const focusPaddingClass = computed(() => {
if (props.iconName && props.iconPosition === 'left') return 'focus:!pl-11'
return 'focus:pl-[11px]'
})
const iconPositionClass = computed(() => {
const sideClass = props.iconPosition === 'left' ? 'left-2' : 'right-2'
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
})
</script>
<style scoped>
.floating-label {
background: white;
padding: 0 0.25rem;
}
.grow-height {
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease;
}
.grow-height:focus {
padding-top: 0.625rem;
padding-bottom: 0.625rem;
}
@media (prefers-reduced-motion: reduce) {
.grow-height { transition: none; }
}
</style>

View File

@@ -0,0 +1,200 @@
<template>
<Story
title="Input/Amount"
>
<MalioInputAmount/>
</Story>
</template>
<docs lang="md">
# MalioInputAmount
Composant input dédié à la saisie dun montant décimal avec label flottant,
états visuels (erreur / succès) et icône configurable.
------------------------------------------------------------------------
## Props détaillées
### id
- Type: string
- Description: Identifiant HTML de linput.
- Comportement: Si non fourni, un id unique est généré automatiquement.
### label
- Type: string
- Description: Texte affiché comme label flottant.
- Comportement: Si absent, aucun label nest rendu.
### name
- Type: string
- Description: Attribut `name` de linput, utile dans les formulaires.
### autocomplete
- Type: string
- Description: Valeur de lattribut `autocomplete`.
- Défaut: `off`
### modelValue
- Type: string | null | undefined
- Description: Valeur contrôlée du composant.
- Comportement:
- Si défini, le composant fonctionne via `v-model`.
- Sinon, il conserve une valeur locale interne.
------------------------------------------------------------------------
## Apparence & Style
### inputClass
- Type: string
- Description: Classes CSS additionnelles appliquées à linput.
### labelClass
- Type: string
- Description: Classes CSS additionnelles appliquées au label.
### groupClass
- Type: string
- Description: Classes CSS additionnelles appliquées au conteneur.
------------------------------------------------------------------------
## Validation & Contraintes
### required
- Type: boolean
- Description: Ajoute lattribut HTML `required`.
### maxLength
- Type: number | string
- Description: Longueur maximale autorisée.
### minLength
- Type: number | string
- Description: Longueur minimale autorisée.
### disabled
- Type: boolean
- Description: Désactive complètement le champ.
### readonly
- Type: boolean
- Description: Rend le champ non modifiable mais focusable.
------------------------------------------------------------------------
## États & Messages
### hint
- Type: string
- Description: Message daide affiché sous le champ.
### error
- Type: string
- Description: Message derreur.
- Effet:
- Active létat visuel erreur.
- Positionne `aria-invalid="true"`.
- Est prioritaire sur `success` et `hint`.
### success
- Type: string
- Description: Message de succès.
- Effet:
- Actif uniquement si `error` est absent.
------------------------------------------------------------------------
## Icône
### iconName
- Type: string
- Description: Nom de licône affichée dans le champ.
- Défaut: `mdi:currency-eur`
### iconPosition
- Type: `'left' | 'right'`
- Description: Position de licône dans le champ.
- Défaut: `right`
### iconSize
- Type: string | number
- Description: Taille de licône.
### iconColor
- Type: string
- Description: Classe de couleur de licône en état neutre.
------------------------------------------------------------------------
## Comportement montant
- Le champ est rendu en `type="text"` avec `inputmode="decimal"`.
- Le séparateur décimal affiché par défaut est `.`.
- Les virgules saisies par lutilisateur sont converties en points.
- Tous les caractères non numériques, sauf le séparateur décimal, sont supprimés.
- La partie décimale est limitée à 2 chiffres.
### Exemples de normalisation
- `12,5` devient `12.5`
- `0012,345abc` devient `12.34`
- `,5` devient `0.5`
### Formatage au blur
- `12` devient `12.00`
- `12.5` devient `12.50`
------------------------------------------------------------------------
## Priorité visuelle
1. `error`
2. `success`
3. état neutre
------------------------------------------------------------------------
## Accessibilité
- `aria-invalid` est activé si `error` existe.
- `aria-describedby` référence le message affiché sous le champ.
- Le composant fonctionne avec ou sans `v-model`.
------------------------------------------------------------------------
## Events
### update:modelValue
- Émis à chaque modification de linput.
- Émis aussi au `blur` si la valeur est reformatée.
- Permet lutilisation avec `v-model`.
</docs>
<script setup lang="ts">
import MalioInputAmount from '../components/malio/InputAmount.vue'
</script>