Files
malio-layer-ui/docs/superpowers/plans/2026-06-09-inputamount-separateurs-milliers.md
T
tristan 336cb9e315 feat(ui) : saisie clavier MalioDate + bouton « + » InputEmail + séparateurs InputAmount (#MUI-42) (#68)
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>
2026-06-09 15:39:38 +00:00

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 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 :

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 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 :

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 → 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.