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>
7.0 KiB
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 decurrentValue. - 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 :
- Lire
rawText = target.valueetcaret = target.selectionStart. model = normalizeAmount(rawText).- Plafond
maxLength(cf. §3) : si dépassement, ignorer le keystroke (restaurer l'affichage précédent, ne pas émettre). - Sinon :
display = formatGroupedAmount(model); écriretarget.value = display; émettreupdate:modelValue(model). - 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 dansdisplay.
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èsmodel = normalizeAmount(rawText): siprops.maxLength != nulletmodel.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.
- on restaure
maxLengthborne donc la longueur de la chaînemodelValue(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
integerPartpar paquets de 3 depuis la droite avec une espace' '. - Si le modèle contient
.→groupedInteger + ',' + decimalPart(decimalPart éventuellement vide). - Sinon →
groupedInteger.
- Si
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.valuepassent de la valeur brute (1234.56) à la valeur groupée (1 234,56). - Les assertions d'émission
update:modelValuerestent inchangées (modèle propre :'1234.56','0.5',''…). - Nouveaux tests : groupement à la frappe d'un grand montant (
1234567→ affichage1 234 567, émis1234567) ;maxLengthplafonne 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),modelValuereste propre ('1234567.89'),maxLengthborne 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.
minLengthsur le modèle (reste natif sur l'affichage).- Passage à
maskaen mode number (approche écartée au profit dunormalizeAmountexistant). - Devises / symboles dynamiques (l'icône € existante est conservée telle quelle).