Files
malio-layer-ui/docs/superpowers/specs/2026-06-09-inputamount-separateurs-milliers-design.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

7.0 KiB

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

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
}

<input type="text"> 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'<input> (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): stringdé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).