diff --git a/docs/superpowers/specs/2026-06-09-inputamount-separateurs-milliers-design.md b/docs/superpowers/specs/2026-06-09-inputamount-separateurs-milliers-design.md new file mode 100644 index 0000000..59074ec --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-inputamount-separateurs-milliers-design.md @@ -0,0 +1,117 @@ +# 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).