Files
malio-layer-ui/docs/superpowers/specs/2026-06-09-inputemail-bouton-ajout-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

6.4 KiB

MalioInputEmail — bouton « + » d'ajout (event add)

Date : 2026-06-09 Statut : Validé, prêt pour plan d'implémentation Périmètre : MalioInputEmail uniquement.

Objectif

Ajouter à MalioInputEmail le même bouton « + » que MalioInputPhone : un bouton optionnel à droite du champ qui émet un event add, permettant au consommateur d'ajouter dynamiquement un autre champ email (ou toute autre action).

La logique email existante (ajoutée en MUI-41) est conservée intégralement : sanitizeEmail (suppression des espaces), prop lowercase, gestion du caret dans onInput, type="email" / inputmode="email". Ce travail n'y touche pas.

Décisions validées

Sujet Décision
API Calquée sur MalioInputPhone : props addable / addIconName / addButtonLabel, event add, data-test="add-button".
Collision icône/bouton L'icône email étant à droite par défaut, quand addable est actif l'icône passe automatiquement à gauche et le « + » occupe la droite (disposition éprouvée de Phone).
Libellé par défaut addButtonLabel défaut 'Ajouter une adresse email'.
Garde désactivé Le bouton n'émet pas add si disabled ou readonly (comme Phone).
Approche Recopier le pattern de Phone dans Email (pas d'extraction d'un composant partagé — noté comme cleanup futur possible, hors scope).

Conception détaillée

1. Props ajoutées (interface + défauts)

    addable?: boolean       // défaut: false
    addIconName?: string    // défaut: 'mdi:plus'
    addButtonLabel?: string // défaut: 'Ajouter une adresse email'

2. Event ajouté

L'defineEmits passe de :

const emit = defineEmits<{
  (event: 'update:modelValue', value: string): void
}>()

à :

const emit = defineEmits<{
  (event: 'update:modelValue', value: string): void
  (event: 'add'): void
}>()

3. Position d'icône « effective »

Nouvelle règle, unique source du repositionnement :

const effectiveIconPosition = computed(() =>
  props.addable && props.iconName ? 'left' : props.iconPosition,
)

Les quatre computeds qui dépendent aujourd'hui de props.iconPosition utilisent désormais effectiveIconPosition.value :

  • iconInputPaddingClass
  • iconPositionClass
  • labelPositionClass
  • focusPaddingClass

Conséquence :

  • addable=falseeffectiveIconPosition === props.iconPosition → comportement strictement identique à aujourd'hui.
  • addable=true (avec une icône) → icône à gauche + espace réservé à droite pour le bouton.

4. iconInputPaddingClass aligné sur Phone

Remplacement de l'implémentation actuelle d'Email par la forme éprouvée de Phone (rendu identique dans le cas non-addable car la classe de base pl-3 pr-3 est commune aux deux composants) :

const iconInputPaddingClass = computed(() => {
  const leftIcon = props.iconName && effectiveIconPosition.value === 'left'
  const rightIcon = props.iconName && effectiveIconPosition.value === 'right'
  const parts: string[] = []
  if (leftIcon) parts.push('!pl-11')
  if (rightIcon || props.addable) parts.push('!pr-10')
  return parts.join(' ')
})

5. Bouton dans le template

Inséré après le bloc <IconifyIcon v-if="iconName">, à l'identique de Phone :

      <button
        v-if="addable"
        type="button"
        :disabled="disabled"
        :aria-label="addButtonLabel"
        data-test="add-button"
        :class="mergedAddButtonClass"
        @click="onAdd"
      >
        <IconifyIcon
          :icon="addIconName"
          :width="24"
          :height="24"
          data-test="add-icon"
        />
      </button>

6. mergedAddButtonClass (copie de Phone)

const mergedAddButtonClass = computed(() =>
  twMerge(
    'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer transition-opacity hover:opacity-70',
    iconStateClass.value,
    props.disabled ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '',
  ),
)

(iconStateClass existe déjà dans Email.)

7. Handler onAdd (copie de Phone)

const onAdd = () => {
  if (props.disabled || props.readonly) return
  emit('add')
}

Quatre computeds à modifier — détail

Aujourd'hui dans InputEmail.vue ils référencent props.iconPosition ; ils doivent référencer effectiveIconPosition.value. Les corps restent identiques par ailleurs :

  • iconPositionClass : effectiveIconPosition.value === 'left' ? 'left-[10px]' : 'right-[10px]'
  • labelPositionClass : props.iconName && effectiveIconPosition.value === 'left' ? 'left-11' : 'left-3'
  • focusPaddingClass : props.iconName && effectiveIconPosition.value === 'left' ? 'focus:!pl-11' : 'focus:pl-[11px]'
  • iconInputPaddingClass : remplacé par la version §4.

Tests (InputEmail.test.ts)

Ajouts (mirroring InputPhone.test.ts), sans toucher aux tests existants de sanitisation/lowercase :

  • addable=false (défaut) → pas de [data-test="add-button"].
  • addable=true[data-test="add-button"] présent.
  • Clic sur le bouton → un event add émis (longueur 1).
  • addable + disabled → clic n'émet pas add ; le bouton a l'attribut disabled.
  • addable + readonly → clic n'émet pas add ; le bouton n'a PAS l'attribut natif disabled (la garde onAdd bloque).
  • addable=true avec icône → l'icône email est positionnée à gauche (left-[10px] présent / right-[10px] absent sur l'icône [data-test="icon"]).
  • Non-régression : avec addable=false, l'icône reste à droite (right-[10px]).
  • addButtonLabel personnalisé → aria-label respecté ; défaut → 'Ajouter une adresse email'.

Livrables documentaires

  • COMPONENTS.md : ajouter les lignes addable / addIconName / addButtonLabel et l'event add() dans la section ## MalioInputEmail, plus un exemple <MalioInputEmail ... addable @add="..." />.
  • CHANGELOG.md : entrée sous ### Added.
  • Story app/story/input/inputEmail.story.vue : une carte « Addable » avec @add (calquée sur inputPhone.story.vue).
  • Playground .playground/pages/composant/input/inputEmail.vue : un exemple addable avec un handler qui illustre l'ajout d'un champ.

Hors périmètre

  • Extraction d'un composant/bouton partagé entre Phone et Email (refactor dédié futur).
  • Gestion réelle de la liste de champs email côté composant (c'est au consommateur de réagir à l'event add, comme pour Phone).
  • Toute modification de la logique de sanitisation / lowercase / caret existante.