docs(amount) : plan implémentation séparateurs de milliers

This commit is contained in:
2026-06-09 13:45:41 +02:00
parent b9e4f496a4
commit 1a82e27346
@@ -0,0 +1,532 @@
# 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 du `normalizeAmount`/`updateValue` inline et du `:maxlength` natif.
- **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` :
```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` :
```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**
```bash
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 :
```ts
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 :
```ts
const formattedValue = computed(() => formatGroupedAmount(currentValue.value))
```
- [ ] **Step 3 : Binder l'affichage groupé et retirer le `maxlength` natif**
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 `normalizeAmount` inline, `updateValue` et `onInput`**
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 :
```ts
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 :
```ts
// À 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**
```bash
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 » :
```ts
expect(wrapper.get('input').element.value).toBe('12.5')
```
devient :
```ts
expect(wrapper.get('input').element.value).toBe('12,5')
```
Test « accepts commas but normalizes them to dots » :
```ts
expect(wrapper.get('input').element.value).toBe('12.34')
```
devient :
```ts
expect(wrapper.get('input').element.value).toBe('12,34')
```
Test « normalizes a leading decimal separator » :
```ts
expect(wrapper.get('input').element.value).toBe('0.5')
```
devient :
```ts
expect(wrapper.get('input').element.value).toBe('0,5')
```
Test « keeps the normalized decimal value on blur » :
```ts
expect(input.element.value).toBe('12.5')
```
devient :
```ts
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 :
```ts
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**
```bash
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 :
```markdown
Champ montant avec icône devise (euro par défaut).
```
par :
```markdown
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 :
```vue
<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) :
```markdown
* 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**
```bash
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 :
```html
<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 :
```ts
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 :
```html
<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) :
```ts
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**
```bash
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` → affiche `1 234 567` au fil de la frappe ; `modelValue` affiché = `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.89` s'affiche groupée au montage.
---
## Self-Review
**Spec coverage :**
- Modèle propre, séparateurs visuels → Task 2 (binding `formattedValue`, emit `model`).
- Temps réel + curseur → Task 2 Step 4 (`onInput` avec `countSignificant`/`caretFromSignificant`).
- Format FR (espace + virgule) → Task 1 (`formatGroupedAmount`).
- Par défaut sur tous (pas de prop) → aucune prop ajoutée.
- `maxLength` sur le modèle + suppression `maxlength` natif → 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.