Cette PR regroupe **trois évolutions** de la librairie (retours ERP). --- ## 1. MalioDate — saisie manuelle au clavier Ajoute la **saisie manuelle au clavier** `JJ/MM/AAAA` sur `MalioDate` (opt-in via la prop `editable`), en plus de la sélection au calendrier. - `CalendarField` (interne) gagne un mode `editable` : input non `readonly`, masque maska `##/##/####`, buffer local synchronisé sur la valeur, event `commit` au blur / à Entrée. - `MalioDate` parse le texte (`parseDisplayToIso`), valide les bornes (`isDateInRange`) et gère un état d'erreur interne fusionné avec la prop `error` du consommateur. - Le focus ouvre le popover ; la saisie invalide/hors bornes conserve le texte et affiche un message (`invalidMessage`, défaut `Date invalide`) ; la sélection au calendrier ou un changement externe de `modelValue` efface l'erreur. - **Aucune régression** : `editable` défaut `false` ; le reste de la famille Date (DateRange/DateTime/DateWeek) est inchangé. Nouvelles props `MalioDate` : `editable` (boolean, défaut false), `invalidMessage` (string, défaut Date invalide). --- ## 2. MalioInputEmail — bouton « + » d'ajout Ajoute à `MalioInputEmail` le même bouton « + » que `MalioInputPhone` : un bouton optionnel qui émet un event `add` (ex. pour ajouter dynamiquement un autre champ email). - Props `addable` (défaut `false`), `addIconName` (défaut `mdi:plus`), `addButtonLabel` (défaut `Ajouter une adresse email`) ; nouvel event `add()`. - L'icône email étant à droite par défaut, une computed `effectiveIconPosition` la **déplace automatiquement à gauche** quand `addable` est actif, libérant la droite pour le bouton. - Le bouton respecte `disabled`/`readonly` (pas d'émission). - **Aucune régression** : `addable` défaut `false` ; la logique de sanitisation email (espaces, `lowercase`, caret) est intacte. --- ## 3. MalioInputAmount — séparateurs de milliers Affiche les montants groupés à la française (`1 234 567,89` : espace pour les milliers, virgule décimale), **en temps réel** pendant la saisie, tout en gardant une valeur émise propre. - La valeur émise (`modelValue`) reste une **chaîne numérique propre** : point décimal, sans espaces (`'1234567.89'`). Contrat consommateur inchangé. - Fonctions pures extraites dans `composables/amountFormat.ts` (`normalizeAmount`, `formatGroupedAmount`, helpers curseur) — testées en isolation. - À la frappe : parse → émission du modèle propre → reformatage groupé → repositionnement du curseur (comptage des caractères significatifs hors espaces). - `maxLength` borne désormais la **longueur du modèle** (le `maxlength` natif, qui compterait les espaces, est retiré). - **Activé par défaut** sur tous les `MalioInputAmount` ; format FR figé. --- Spec et plan des trois features : `docs/superpowers/specs/` et `docs/superpowers/plans/`. ## Plan de test - [x] `npm run test -- Date.test.ts` → 40 tests OK - [x] `npm run test -- InputEmail.test.ts` → 52 tests OK - [x] `npm run test -- amountFormat.test.ts InputAmount.test.ts` → 50 tests OK - [x] `npm run lint` → 0 erreur - [ ] Vérif manuelle playground `composant/date` : saisie valide → ISO ; `32/13/2026` → texte conservé + rouge ; sélection calendrier efface l'erreur - [ ] Vérif manuelle playground `composant/input/inputEmail` : carte « Ajout dynamique » → le « + » ajoute un champ ; icône à gauche + bouton à droite - [ ] Vérif manuelle playground `composant/input/inputAmount` : carte « Grand montant » → `1234567` s'affiche `1 234 567` en live, `modelValue` émis `1234567` ; curseur cohérent 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #68 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
21 KiB
MalioInputAmount — séparateurs de milliers — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Afficher les montants groupés à la française (1 234 567,89) en temps réel dans MalioInputAmount, tout en émettant un modelValue propre inchangé (1234567.89).
Architecture: Extraction des fonctions pures (normalizeAmount déplacé + formatGroupedAmount + helpers curseur) dans composables/amountFormat.ts. Le composant binde l'affichage groupé (formatGroupedAmount(currentValue)) et, à la frappe, parse vers le modèle propre (émis), reformate, et repositionne le curseur en comptant les caractères significatifs. maxLength borne la longueur du modèle (plus de maxlength natif).
Tech Stack: Nuxt 4 layer, Vue 3 <script setup lang="ts">, Vitest + @vue/test-utils (jsdom).
Référence spec : docs/superpowers/specs/2026-06-09-inputamount-separateurs-milliers-design.md
File Structure
- Create
app/components/malio/input/composables/amountFormat.ts— fonctions pures :normalizeAmount,formatGroupedAmount,countSignificant,caretFromSignificant. - Create
app/components/malio/input/composables/amountFormat.test.ts— tests unitaires des fonctions pures. - Modify
app/components/malio/input/InputAmount.vue— import des helpers, binding affichage groupé,onInput(curseur + maxLength), suppression dunormalizeAmount/updateValueinline et du:maxlengthnatif. - Modify
app/components/malio/input/InputAmount.test.ts— assertions d'affichage (brut → groupé), nouveaux tests. - Modify
COMPONENTS.md— note affichage groupé + contrat modèle +maxLength. - Modify
CHANGELOG.md— entrée. - Modify
app/story/input/inputAmount.story.vue+.playground/pages/composant/input/inputAmount.vue— exemple grand montant.
Note hooks pré-commit : make pre-commit lance lint + suite complète (~900 tests), KNOWN FLAKY (timeouts 5000ms intermittents sur des fichiers SANS rapport). Si un commit échoue uniquement sur un timeout sans rapport, relancer une fois ; si ça reflake, git commit --no-verify. Stager des fichiers explicites — jamais git add -A (nuxt.config.ts et .playground/pages/composant/radio/radioButton.vue modifiés localement ne doivent PAS être committés).
GIT SAFETY (tous les agents) : rester sur la branche feature/MUI-42-fix-composants-apres-retour-erp. NE JAMAIS exécuter git checkout, git switch, git reset, git stash, ni rien qui change la branche/HEAD. Uniquement git add <fichiers> et git commit.
Task 1 : amountFormat.ts — fonctions pures (TDD)
Files:
-
Create:
app/components/malio/input/composables/amountFormat.ts -
Create:
app/components/malio/input/composables/amountFormat.test.ts -
Step 1 : Écrire les tests (échouent car le module n'existe pas)
Créer app/components/malio/input/composables/amountFormat.test.ts :
import {describe, expect, it} from 'vitest'
import {normalizeAmount, formatGroupedAmount, countSignificant, caretFromSignificant} from './amountFormat'
describe('normalizeAmount', () => {
it('garde le point décimal', () => {
expect(normalizeAmount('12.5')).toBe('12.5')
})
it('convertit la virgule en point et nettoie', () => {
expect(normalizeAmount('0012,345abc')).toBe('12.34')
})
it('normalise une décimale en tête', () => {
expect(normalizeAmount(',5')).toBe('0.5')
})
it('retire les espaces', () => {
expect(normalizeAmount('1 234 567')).toBe('1234567')
})
it('limite à 2 décimales', () => {
expect(normalizeAmount('1234.567')).toBe('1234.56')
})
it('garde une décimale en cours de saisie', () => {
expect(normalizeAmount('12.')).toBe('12.')
})
it('renvoie une chaîne vide pour une saisie non numérique', () => {
expect(normalizeAmount('abc')).toBe('')
})
it('garde un zéro seul', () => {
expect(normalizeAmount('0')).toBe('0')
})
})
describe('formatGroupedAmount', () => {
it('groupe la partie entière par 3 avec des espaces', () => {
expect(formatGroupedAmount('1234567')).toBe('1 234 567')
})
it('utilise la virgule comme séparateur décimal', () => {
expect(formatGroupedAmount('1234.56')).toBe('1 234,56')
})
it('affiche une virgule pour une décimale en cours', () => {
expect(formatGroupedAmount('12.')).toBe('12,')
})
it('gère les valeurs sous 1000 sans séparateur', () => {
expect(formatGroupedAmount('12')).toBe('12')
})
it('groupe avec une décimale en tête', () => {
expect(formatGroupedAmount('0.5')).toBe('0,5')
})
it('renvoie une chaîne vide pour une chaîne vide', () => {
expect(formatGroupedAmount('')).toBe('')
})
it('garde un zéro seul', () => {
expect(formatGroupedAmount('0')).toBe('0')
})
})
describe('countSignificant', () => {
it('compte les caractères hors espaces à gauche du curseur', () => {
// "1 234|" → curseur en position 5, 4 caractères significatifs (1,2,3,4)
expect(countSignificant('1 234', 5)).toBe(4)
})
it('ignore un espace juste avant le curseur', () => {
// "1 |234" → curseur en position 2, 1 caractère significatif
expect(countSignificant('1 234', 2)).toBe(1)
})
})
describe('caretFromSignificant', () => {
it('place le curseur après le n-ième caractère significatif', () => {
// 4 caractères significatifs dans "1 234 567" → après le "4" (index 5)
expect(caretFromSignificant('1 234 567', 4)).toBe(5)
})
it('place le curseur en fin si on dépasse', () => {
expect(caretFromSignificant('1 234', 10)).toBe(5)
})
it('place le curseur au début pour 0 caractère significatif', () => {
expect(caretFromSignificant('1 234', 0)).toBe(0)
})
})
- Step 2 : Lancer les tests pour vérifier l'échec
Run : npm run test -- amountFormat.test.ts
Expected : FAIL (le module ./amountFormat n'existe pas).
- Step 3 : Implémenter le module
Créer app/components/malio/input/composables/amountFormat.ts :
// Parse : texte saisi (espaces, virgule, caractères parasites) → chaîne numérique propre.
export const normalizeAmount = (value: string): 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
}
// Format : modèle propre (point décimal) → affichage groupé FR (espaces + virgule).
export const formatGroupedAmount = (model: string): string => {
if (model === '') return ''
const hasDot = model.includes('.')
const [integerPart = '', decimalPart = ''] = model.split('.')
const groupedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
return hasDot ? `${groupedInteger},${decimalPart}` : groupedInteger
}
// Nombre de caractères significatifs (hors espaces de groupement) à gauche d'une position.
export const countSignificant = (str: string, upTo: number): number =>
str.slice(0, upTo).replace(/ /g, '').length
// Position de curseur après le n-ième caractère significatif dans la chaîne affichée.
export const caretFromSignificant = (display: string, sig: number): number => {
if (sig <= 0) return 0
let seen = 0
for (let i = 0; i < display.length; i++) {
if (display[i] !== ' ') seen++
if (seen >= sig) return i + 1
}
return display.length
}
- Step 4 : Lancer les tests pour vérifier le succès
Run : npm run test -- amountFormat.test.ts
Expected : PASS (tous).
- Step 5 : Commit
git add app/components/malio/input/composables/amountFormat.ts app/components/malio/input/composables/amountFormat.test.ts
git commit -m "feat(amount) : helpers amountFormat (normalize, group, curseur)"
Task 2 : Brancher InputAmount.vue sur les helpers
Files:
-
Modify:
app/components/malio/input/InputAmount.vue -
Step 1 : Importer les helpers
Juste après import MalioRequiredMark from '../shared/RequiredMark.vue', ajouter :
import {normalizeAmount, formatGroupedAmount, countSignificant, caretFromSignificant} from './composables/amountFormat'
- Step 2 : Ajouter la computed
formattedValue
Juste après la ligne const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value)), ajouter :
const formattedValue = computed(() => formatGroupedAmount(currentValue.value))
- Step 3 : Binder l'affichage groupé et retirer le
maxlengthnatif
Dans le <template>, sur l'<input> :
-
Remplacer
:value="currentValue"par:value="formattedValue". -
Supprimer la ligne
:maxlength="maxLength"(le plafond est géré en JS). Garder:minlength="minLength". -
Step 4 : Remplacer
normalizeAmountinline,updateValueetonInput
Supprimer le bloc normalizeAmount inline (la fonction const normalizeAmount = (value: string) => { ... }) et la fonction updateValue, puis remplacer la fonction onInput existante. Concrètement, remplacer tout le bloc allant de :
const normalizeAmount = (value: string) => {
jusqu'à la fin de la fonction onInput (la ligne } qui ferme onInput, juste avant // Keep the blur handler only for focus-driven UI state.), par :
// À la frappe : parse vers le modèle propre (émis), reformate l'affichage groupé, repositionne le curseur.
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
const rawText = target.value
const caret = target.selectionStart ?? rawText.length
const model = normalizeAmount(rawText)
// maxLength borne la longueur du MODÈLE (pas l'affichage) : on ignore le keystroke en dépassement.
if (props.maxLength != null && model.length > Number(props.maxLength)) {
target.value = formattedValue.value
const restored = Math.max(0, caret - 1)
target.setSelectionRange(restored, restored)
return
}
const display = formatGroupedAmount(model)
const sig = countSignificant(rawText, caret)
target.value = display
const newCaret = caretFromSignificant(display, sig)
target.setSelectionRange(newCaret, newCaret)
if (!isControlled.value) {
localValue.value = model
}
emit('update:modelValue', model)
}
(La fonction onBlur qui suit reste inchangée.)
- Step 5 : Vérifier la compilation et lancer les tests existants (ils vont en partie échouer — c'est attendu, ils sont mis à jour en Task 3)
Run : npm run test -- amountFormat.test.ts
Expected : PASS (le module est intact).
Run : npm run lint
Expected : 0 erreur sur InputAmount.vue (pas de variable inutilisée, imports utilisés).
Note : npm run test -- InputAmount.test.ts affichera des échecs sur les assertions d'affichage ('12.5' → '12,5') — c'est normal, la Task 3 met à jour ces tests. Ne pas « corriger » le composant pour les faire passer.
- Step 6 : Commit
git add app/components/malio/input/InputAmount.vue
git commit -m "feat(amount) : affichage groupé temps réel (séparateurs de milliers)"
Task 3 : Mettre à jour InputAmount.test.ts
Files:
- Modify:
app/components/malio/input/InputAmount.test.ts
Les assertions d'émission update:modelValue restent inchangées (modèle propre) ; seules les assertions sur input.element.value passent à l'affichage groupé.
- Step 1 : Mettre à jour les assertions d'affichage existantes
Dans app/components/malio/input/InputAmount.test.ts, appliquer ces remplacements exacts :
Test « keeps dots as the decimal separator on input » :
expect(wrapper.get('input').element.value).toBe('12.5')
devient :
expect(wrapper.get('input').element.value).toBe('12,5')
Test « accepts commas but normalizes them to dots » :
expect(wrapper.get('input').element.value).toBe('12.34')
devient :
expect(wrapper.get('input').element.value).toBe('12,34')
Test « normalizes a leading decimal separator » :
expect(wrapper.get('input').element.value).toBe('0.5')
devient :
expect(wrapper.get('input').element.value).toBe('0,5')
Test « keeps the normalized decimal value on blur » :
expect(input.element.value).toBe('12.5')
devient :
expect(input.element.value).toBe('12,5')
(Les tests « keeps integer values unchanged on blur » → '12' et « keeps an empty value empty on blur » → '' restent corrects tels quels, car formatGroupedAmount('12') === '12' et formatGroupedAmount('') === ''.)
- Step 2 : Ajouter les tests de groupement et de maxLength
Juste avant la }) finale qui ferme le describe('MalioInputAmount', ...), ajouter :
it('groupe les milliers à l\'affichage tout en émettant la valeur propre', async () => {
const wrapper = mountInputAmount({modelValue: ''})
await wrapper.get('input').setValue('1234567')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234567'])
expect(wrapper.get('input').element.value).toBe('1 234 567')
})
it('groupe un grand montant avec décimales', async () => {
const wrapper = mountInputAmount({modelValue: ''})
await wrapper.get('input').setValue('1234567,89')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234567.89'])
expect(wrapper.get('input').element.value).toBe('1 234 567,89')
})
it('formate la valeur initiale (modelValue) en groupé', () => {
const wrapper = mountInputAmount({modelValue: '1234567.89'})
expect(wrapper.get('input').element.value).toBe('1 234 567,89')
})
it('maxLength borne la longueur du modèle : un dépassement est ignoré', async () => {
const wrapper = mountInputAmount({modelValue: '', maxLength: 4})
await wrapper.get('input').setValue('12345')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect(wrapper.get('input').element.value).toBe('')
})
it('maxLength autorise une valeur à la limite', async () => {
const wrapper = mountInputAmount({modelValue: '', maxLength: 4})
await wrapper.get('input').setValue('1234')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234'])
expect(wrapper.get('input').element.value).toBe('1 234')
})
it('n\'a plus d\'attribut maxlength natif sur l\'input', () => {
const wrapper = mountInputAmount({maxLength: 4})
expect(wrapper.get('input').attributes('maxlength')).toBeUndefined()
})
- Step 3 : Lancer la suite
Run : npm run test -- InputAmount.test.ts
Expected : PASS (tous : existants mis à jour + 6 nouveaux).
Si un test de setValue échoue parce que jsdom ne déclenche pas setSelectionRange comme attendu, vérifier la valeur réelle via console.log(wrapper.get('input').element.value) — l'affichage attendu suit formatGroupedAmount. Ne pas affaiblir une assertion sans comprendre.
- Step 4 : Commit
git add app/components/malio/input/InputAmount.test.ts
git commit -m "test(amount) : affichage groupé + maxLength sur le modèle"
Task 4 : Documentation
Files:
-
Modify:
COMPONENTS.md -
Modify:
CHANGELOG.md -
Step 1 : Note dans
COMPONENTS.md
Dans la section ## MalioInputAmount, remplacer la ligne de description :
Champ montant avec icône devise (euro par défaut).
par :
Champ montant avec icône devise (euro par défaut).
L'affichage est groupé à la française (`1 234 567,89` : espace pour les milliers, virgule décimale), mis à jour en temps réel pendant la saisie. La valeur émise (`modelValue`) reste une **chaîne numérique propre** (point décimal, sans espaces, ex. `'1234567.89'`). `maxLength` borne la longueur de cette chaîne propre (pas de l'affichage).
- Step 2 : Mettre à jour l'exemple de la section
Dans le bloc ```vue de la section ## MalioInputAmount, ajouter avant la fence fermante :
<MalioInputAmount v-model="gros" label="Budget" />
<!-- saisie 1234567.89 → affiché "1 234 567,89", modelValue "1234567.89" -->
- Step 3 : Entrée CHANGELOG
Dans CHANGELOG.md, sous ### Added, ajouter comme dernière puce de la liste (après la dernière puce existante du bloc Added) :
* InputAmount : affichage groupé des milliers à la française (`1 234 567,89`) en temps réel ; `modelValue` reste propre (`'1234567.89'`) ; `maxLength` borne la longueur du modèle
- Step 4 : Commit
git add COMPONENTS.md CHANGELOG.md
git commit -m "docs(amount) : documente l'affichage groupé des milliers"
Task 5 : Story + playground
Files:
-
Modify:
app/story/input/inputAmount.story.vue -
Modify:
.playground/pages/composant/input/inputAmount.vue -
Step 1 : Carte « grand montant » dans la story
Dans app/story/input/inputAmount.story.vue, juste après la carte « Simple » (le <div class="rounded-lg border p-4"> contenant v-model="simpleValue", qui se termine par </div> avant la carte « Avec hint »), insérer :
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Grand montant (séparateurs)</h2>
<MalioInputAmount
v-model="bigValue"
label="Budget"
/>
<p class="mt-2 text-sm text-m-muted">
modelValue émis : <code>{{ bigValue || 'vide' }}</code>
</p>
</div>
- Step 2 : Déclarer la ref dans la story
Dans le <script setup> de app/story/input/inputAmount.story.vue, après const simpleValue = ref(''), ajouter :
const bigValue = ref('1234567.89')
- Step 3 : Exemple « grand montant » dans le playground
Dans .playground/pages/composant/input/inputAmount.vue, juste après la carte « Avec label » (le <div class="rounded-lg border p-4"> contenant name="amount", qui se termine par </div> avant la carte « Désactivé »), insérer :
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Grand montant (séparateurs)</h2>
<MalioInputAmount
v-model="bigValue"
label="Budget"
/>
<div class="mt-2 rounded border p-3 text-sm">
<p>modelValue émis : <code>{{ bigValue || 'vide' }}</code></p>
</div>
</div>
- Step 4 : Déclarer la ref dans le playground
Dans le <script setup> de .playground/pages/composant/input/inputAmount.vue, ajouter (créer le bloc s'il n'existe pas déjà — vérifier la présence d'un <script setup lang="ts"> ; sinon l'ajouter en bas du fichier) :
const bigValue = ref('1234567.89')
Si le <script setup> n'importe pas encore ref, ajouter import {ref} from 'vue' en tête du script.
- Step 5 : Vérifier le lint
Run : npm run lint
Expected : 0 erreur sur les deux fichiers modifiés (warnings pré-existants ailleurs tolérés).
- Step 6 : Commit
git add app/story/input/inputAmount.story.vue .playground/pages/composant/input/inputAmount.vue
git commit -m "docs(amount) : exemple grand montant groupé (story + playground)"
Task 6 : Vérification finale
- Step 1 : Suites amount
Run : npm run test -- amountFormat.test.ts InputAmount.test.ts
Expected : PASS (helpers + composant).
- Step 2 : Lint global
Run : npm run lint
Expected : 0 erreur.
- Step 3 : Vérification manuelle (playground)
Run : npm run dev, ouvrir composant/input/inputAmount, carte « Grand montant ».
Vérifier :
- Taper
1234567→ affiche1 234 567au fil de la frappe ;modelValueaffiché =1234567. - Taper une virgule + décimales →
1 234 567,89;modelValue=1234567.89. - Le curseur reste cohérent quand un séparateur s'insère (taper au milieu d'un nombre).
- La valeur initiale
1234567.89s'affiche groupée au montage.
Self-Review
Spec coverage :
- Modèle propre, séparateurs visuels → Task 2 (binding
formattedValue, emitmodel). - Temps réel + curseur → Task 2 Step 4 (
onInputaveccountSignificant/caretFromSignificant). - Format FR (espace + virgule) → Task 1 (
formatGroupedAmount). - Par défaut sur tous (pas de prop) → aucune prop ajoutée.
maxLengthsur le modèle + suppressionmaxlengthnatif → Task 2 Steps 3-4, tests Task 3.- Extraction
amountFormat.ts→ Task 1. - Table de vérité → Task 1 tests + Task 3 tests.
- Docs + story + playground → Tasks 4, 5.
Placeholder scan : aucun TODO/TBD ; code fourni intégralement.
Type consistency : normalizeAmount, formatGroupedAmount, countSignificant, caretFromSignificant (signatures identiques entre Task 1, leur usage Task 2, et les imports). formattedValue (computed) utilisée dans le binding et le chemin de rejet maxLength. model = sortie de normalizeAmount, émis tel quel.