diff --git a/.playground/pages/composant/inputAmount.vue b/.playground/pages/composant/inputAmount.vue new file mode 100644 index 0000000..1006ee6 --- /dev/null +++ b/.playground/pages/composant/inputAmount.vue @@ -0,0 +1,60 @@ + + + diff --git a/app/components/malio/InputAmount.test.ts b/app/components/malio/InputAmount.test.ts new file mode 100644 index 0000000..222ce8a --- /dev/null +++ b/app/components/malio/InputAmount.test.ts @@ -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 + +const mountInputAmount = (props: InputAmountProps = {}) => + mount(InputAmountForTest, { + props, + global: { + stubs: { + IconifyIcon: { + template: '', + }, + }, + }, + }) + +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') + }) +}) diff --git a/app/components/malio/InputAmount.vue b/app/components/malio/InputAmount.vue new file mode 100644 index 0000000..ed3a4d7 --- /dev/null +++ b/app/components/malio/InputAmount.vue @@ -0,0 +1,255 @@ + + + + + diff --git a/app/story/inputAmount.story.vue b/app/story/inputAmount.story.vue new file mode 100644 index 0000000..84939de --- /dev/null +++ b/app/story/inputAmount.story.vue @@ -0,0 +1,200 @@ + + + +# MalioInputAmount + +Composant input dédié à la saisie d’un 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 l’input. +- 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 n’est rendu. + +### name + +- Type: string +- Description: Attribut `name` de l’input, utile dans les formulaires. + +### autocomplete + +- Type: string +- Description: Valeur de l’attribut `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 à l’input. + +### 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 l’attribut 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 d’aide affiché sous le champ. + +### error + +- Type: string +- Description: Message d’erreur. +- 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 l’icône affichée dans le champ. +- Défaut: `mdi:currency-eur` + +### iconPosition + +- Type: `'left' | 'right'` +- Description: Position de l’icône dans le champ. +- Défaut: `right` + +### iconSize + +- Type: string | number +- Description: Taille de l’icône. + +### iconColor + +- Type: string +- Description: Classe de couleur de l’icô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 l’utilisateur 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 l’input. +- Émis aussi au `blur` si la valeur est reformatée. +- Permet l’utilisation avec `v-model`. + + + +