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 :
iconInputPaddingClassiconPositionClasslabelPositionClassfocusPaddingClass
Conséquence :
addable=false→effectiveIconPosition === 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 pasadd; le bouton a l'attributdisabled.addable + readonly→ clic n'émet pasadd; le bouton n'a PAS l'attribut natifdisabled(la gardeonAddbloque).addable=trueavec 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]). addButtonLabelpersonnalisé →aria-labelrespecté ; défaut →'Ajouter une adresse email'.
Livrables documentaires
COMPONENTS.md: ajouter les lignesaddable/addIconName/addButtonLabelet l'eventadd()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 surinputPhone.story.vue). - Playground
.playground/pages/composant/input/inputEmail.vue: un exempleaddableavec 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.