336cb9e315
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>
118 lines
7.0 KiB
Markdown
118 lines
7.0 KiB
Markdown
# 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
|
|
}
|
|
```
|
|
|
|
`<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): 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).
|