# MalioInputAmount — séparateurs de milliers (affichage groupé FR) **Date :** 2026-06-09 **Statut :** Validé, prêt pour plan d'implémentation **Périmètre :** `MalioInputAmount` uniquement. ## Objectif Afficher les montants avec des **séparateurs de milliers** (espaces) et une **virgule décimale**, à la française : `1 234 567,89`. Demande du client ERP. Le formatage est **purement visuel** ; la valeur émise reste une chaîne numérique propre. ## Décisions validées | Sujet | Décision | |-------|----------| | Valeur émise (`modelValue`) | Reste **propre** : point décimal, sans espaces (`'1234567.89'`). Contrat consommateur inchangé. Les séparateurs ne sont qu'à l'affichage. | | Moment du formatage | **Temps réel** (à la frappe), avec gestion du curseur. | | Format affiché | Français : **espace** pour les milliers, **virgule** pour les décimales (`1 234 567,89`). | | Activation | **Par défaut sur tous** les `MalioInputAmount` (pas de prop opt-in). | | `maxLength` | S'applique à la **longueur du `modelValue`** (chaîne propre), **pas** à l'affichage. Le `maxlength` natif (qui compterait les espaces) est retiré ; le plafond est appliqué en JS. | | Extraction | Les fonctions pures vont dans `app/components/malio/input/composables/amountFormat.ts` (testables en isolation). | ## Conception détaillée ### 1. Contrat de valeur & flux de données - **Modèle (émis)** : sortie inchangée de `normalizeAmount` — point décimal, max 2 décimales, zéros de tête retirés, `''` si vide. Ex. `1234567.89`. - **Affichage** : `formatGroupedAmount(model)` groupe la partie entière par 3 avec des espaces et remplace le point par une virgule. Ex. `1 234 567,89`. Si le modèle finit par `.` (décimale en cours, ex. `12.`), l'affichage finit par `,` (`12,`). - **Binding** : l'input affiche `formatGroupedAmount(currentValue)` au lieu de `currentValue`. - **Parse** : à la frappe, le texte saisi (avec espaces/virgule) repasse par `normalizeAmount` → modèle propre → émis. ### 2. Temps réel & gestion du curseur À chaque `@input` : 1. Lire `rawText = target.value` et `caret = target.selectionStart`. 2. `model = normalizeAmount(rawText)`. 3. **Plafond `maxLength`** (cf. §3) : si dépassement, ignorer le keystroke (restaurer l'affichage précédent, ne pas émettre). 4. Sinon : `display = formatGroupedAmount(model)` ; écrire `target.value = display` ; **émettre** `update:modelValue(model)`. 5. **Repositionner le curseur** : compter les caractères significatifs (tout sauf les espaces de groupement) à gauche du curseur dans `rawText`, puis placer le curseur après ce même nombre de caractères significatifs dans `display`. Helpers curseur (purs) : ```ts export const countSignificant = (str: string, upTo: number): number => str.slice(0, upTo).replace(/ /g, '').length export const caretFromSignificant = (display: string, sig: number): number => { let seen = 0 for (let i = 0; i < display.length; i++) { if (display[i] !== ' ') seen++ if (seen >= sig) return i + 1 } return display.length } ``` `` supporte l'API de sélection, donc `setSelectionRange` fonctionne directement (pas de try/catch nécessaire). ### 3. `maxLength` sur le modèle - On **retire** le binding `:maxlength="maxLength"` natif de l'`` (il compterait les espaces de l'affichage). - Dans `onInput`, après `model = normalizeAmount(rawText)` : si `props.maxLength != null` **et** `model.length > Number(props.maxLength)`, on **ignore** le keystroke : - on restaure `target.value = formatGroupedAmount(currentValue)` (modèle précédent), - on replace le curseur à `max(0, caret - 1)` (le caractère refusé n'est pas inséré), - on **n'émet pas**. - `maxLength` borne donc la **longueur de la chaîne `modelValue`** (point décimal inclus). Ce point est documenté explicitement. - `minLength` : laissé tel quel (attribut natif de validation). Connu : il s'évalue sur le texte affiché ; hors périmètre de cette évolution. ### 4. Helpers extraits — `composables/amountFormat.ts` - `normalizeAmount(value: string): string` — **déplacé tel quel** depuis le composant (parse). - `formatGroupedAmount(model: string): string` — nouveau (format groupé FR). Algorithme : - Si `model === ''` → `''`. - Séparer sur `.` → `integerPart`, `decimalPart` (présent ssi le modèle contient `.`). - Grouper `integerPart` par paquets de 3 depuis la droite avec une espace `' '`. - Si le modèle contient `.` → `groupedInteger + ',' + decimalPart` (decimalPart éventuellement vide). - Sinon → `groupedInteger`. - `countSignificant`, `caretFromSignificant` — helpers curseur (purs). Le composant importe ces helpers ; la logique DOM (lecture `target.value`, `setSelectionRange`) reste dans `InputAmount.vue`. ### 5. Table de vérité (format/parse) | Saisie utilisateur | `modelValue` émis | Affichage (`formatGroupedAmount`) | |---|---|---| | `1234567` | `1234567` | `1 234 567` | | `1234,56` ou `1234.56` | `1234.56` | `1 234,56` | | `12.` (décimale en cours) | `12.` | `12,` | | `,5` | `0.5` | `0,5` | | `0012345abc` | `12345` | `12 345` | | `1234.567` (3 décimales) | `1234.56` | `1 234,56` | | `` (vide) | `` | `` | | `0` | `0` | `0` | ## Tests **`composables/amountFormat.test.ts`** (nouveau) : - `normalizeAmount` : reprise des cas existants (espaces, virgule→point, zéros de tête, 2 décimales max, vide, décimale en tête). - `formatGroupedAmount` : table §5 (groupement par 3, virgule décimale, `12.`→`12,`, vide→vide, nombres < 1000 inchangés). - `countSignificant` / `caretFromSignificant` : positions de curseur clés (avant/après un espace inséré, en fin de chaîne). **`InputAmount.test.ts`** (mis à jour) : - Les assertions `input.element.value` passent de la valeur brute (`1234.56`) à la valeur groupée (`1 234,56`). - Les assertions d'émission `update:modelValue` restent **inchangées** (modèle propre : `'1234.56'`, `'0.5'`, `''`…). - Nouveaux tests : groupement à la frappe d'un grand montant (`1234567` → affichage `1 234 567`, émis `1234567`) ; `maxLength` plafonne le modèle (un keystroke au-delà est ignoré, pas d'émission supplémentaire) ; position du curseur après insertion d'un séparateur. ## Livrables documentaires - `COMPONENTS.md` : note dans la section `## MalioInputAmount` — affichage groupé FR (`1 234 567,89`), `modelValue` reste propre (`'1234567.89'`), `maxLength` borne la longueur du modèle. - `CHANGELOG.md` : entrée sous `### Added` / `### Changed`. - Story `app/story/input/inputAmount.story.vue` : exemple grand montant montrant les séparateurs. - Playground `.playground/pages/composant/input/inputAmount.vue` : exemple grand montant + affichage de la valeur ISO/propre émise. ## Hors périmètre - Internationalisation configurable (autres locales / séparateurs paramétrables) — on fige le format FR. - `minLength` sur le modèle (reste natif sur l'affichage). - Passage à `maska` en mode number (approche écartée au profit du `normalizeAmount` existant). - Devises / symboles dynamiques (l'icône € existante est conservée telle quelle).