17 KiB
MalioInputEmail — bouton « + » d'ajout — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ajouter à MalioInputEmail un bouton « + » optionnel (prop addable) qui émet un event add, calqué sur MalioInputPhone, sans toucher à la logique de sanitisation email existante.
Architecture: Recopie du pattern addable de InputPhone.vue dans InputEmail.vue (props addable/addIconName/addButtonLabel, event add, bouton data-test="add-button"). L'icône email étant à droite par défaut, une nouvelle computed effectiveIconPosition la force à gauche quand addable est actif, libérant la droite pour le bouton. Aucune modification de onInput/sanitizeEmail/lowercase.
Tech Stack: Nuxt 4 layer, Vue 3 <script setup lang="ts">, @iconify/vue (Icon), tailwind-merge, Vitest + @vue/test-utils (jsdom).
Référence spec : docs/superpowers/specs/2026-06-09-inputemail-bouton-ajout-design.md
File Structure
- Modify
app/components/malio/input/InputEmail.vue— propsaddable/addIconName/addButtonLabel, eventadd,effectiveIconPosition, 4 computeds repointées, bouton + handleronAdd+mergedAddButtonClass. - Modify
app/components/malio/input/InputEmail.test.ts— tests du bouton + repositionnement icône. - Modify
COMPONENTS.md— props + event + exemple. - Modify
CHANGELOG.md— entrée de version. - Modify
app/story/input/inputEmail.story.vue— carte « addable ». - Modify
.playground/pages/composant/input/inputEmail.vue— exemple d'ajout dynamique.
Note hooks pré-commit : le repo a un hook make pre-commit (lint + suite complète ~888 tests) KNOWN FLAKY (timeouts 5000ms intermittents sur des fichiers SANS rapport). Si un commit échoue uniquement sur un timeout de test sans rapport, relancer une fois ; si ça reflake, git commit --no-verify. Toujours stager des fichiers explicites — jamais git add -A (le nuxt.config.ts et .playground/pages/composant/radio/radioButton.vue modifiés localement ne doivent PAS être committés).
Le composant de référence est app/components/malio/input/InputPhone.vue (le pattern addable y est déjà implémenté à l'identique).
Task 1 : InputEmail.vue — bouton addable + icône effective
Files:
- Modify:
app/components/malio/input/InputEmail.vue
Comportement attendu : addable=false (défaut) ⇒ rendu strictement inchangé ; addable=true ⇒ bouton « + » à droite, icône email à gauche, event add émis au clic (sauf disabled/readonly).
- Step 1 : Ajouter les props
addable/addIconName/addButtonLabel
Dans defineProps<{...}>(), ajouter ces trois lignes juste après iconColor?: string :
addable?: boolean
addIconName?: string
addButtonLabel?: string
Dans withDefaults(..., { ... }), ajouter juste après iconColor: 'text-m-muted', :
addable: false,
addIconName: 'mdi:plus',
addButtonLabel: 'Ajouter une adresse email',
- Step 2 : Ajouter l'event
add
Remplacer :
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
}>()
par :
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
(event: 'add'): void
}>()
- Step 3 : Ajouter le handler
onAdd
Juste après la fonction onInput (après son } de fermeture, avant const iconInputPaddingClass), ajouter :
const onAdd = () => {
if (props.disabled || props.readonly) return
emit('add')
}
- Step 4 : Ajouter
effectiveIconPositionet réécrireiconInputPaddingClass
Remplacer le bloc actuel :
const iconInputPaddingClass = computed(() => {
if (!props.iconName) return ''
return props.iconPosition === 'left' ? '!pl-11 !pr-3' : '!pl-3 !pr-10'
})
par :
const effectiveIconPosition = computed(() =>
props.addable && props.iconName ? 'left' : props.iconPosition,
)
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(' ')
})
- Step 5 : Repointer
labelPositionClass,focusPaddingClass,iconPositionClasssureffectiveIconPosition
Remplacer :
const labelPositionClass = computed(() => {
if (props.iconName && props.iconPosition === 'left') return 'left-11'
return 'left-3'
})
const focusPaddingClass = computed(() => {
if (props.iconName && props.iconPosition === 'left') return 'focus:!pl-11'
return 'focus:pl-[11px]'
})
const iconPositionClass = computed(() => {
const sideClass = props.iconPosition === 'left' ? 'left-[10px]' : 'right-[10px]'
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
})
par :
const labelPositionClass = computed(() => {
if (props.iconName && effectiveIconPosition.value === 'left') return 'left-11'
return 'left-3'
})
const focusPaddingClass = computed(() => {
if (props.iconName && effectiveIconPosition.value === 'left') return 'focus:!pl-11'
return 'focus:pl-[11px]'
})
const iconPositionClass = computed(() => {
const sideClass = effectiveIconPosition.value === 'left' ? 'left-[10px]' : 'right-[10px]'
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
})
- Step 6 : Ajouter la computed
mergedAddButtonClass
Juste après la computed mergedLabelClass (après son ) de fermeture, avant const describedBy), ajouter :
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' : '',
),
)
- Step 7 : Ajouter le bouton dans le template
Dans le template, juste après le bloc <IconifyIcon v-if="iconName" ... /> (sa balise fermante />) et avant la </div> qui ferme le conteneur du champ, insérer :
<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>
- Step 8 : Vérifier la non-régression
Run : npm run test -- InputEmail.test.ts
Expected : PASS — tous les tests existants passent toujours (le cas addable=false est strictement inchangé : icône à droite, paddings identiques).
- Step 9 : Commit
git add app/components/malio/input/InputEmail.vue
git commit -m "feat(email) : bouton + d'ajout (event add) sur MalioInputEmail"
Task 2 : Tests du bouton addable
Files:
- Modify:
app/components/malio/input/InputEmail.test.ts
Le fichier utilise déjà un helper mountComponent(props) qui stub IconifyIcon en <span data-test="icon" v-bind="$attrs" />. L'icône email rend data-test="icon" ; le <button> rend data-test="add-button" et son icône interne data-test="add-icon" — donc [data-test="icon"] ne matche que l'icône email.
- Step 1 : Étendre le type
InputEmailProps
Dans le type InputEmailProps (en tête de fichier), ajouter après lowercase?: boolean :
addable?: boolean
addIconName?: string
addButtonLabel?: string
- Step 2 : Ajouter les tests addable
À l'intérieur du describe('MalioInputEmail', () => { ... }), juste avant la }) finale qui ferme ce describe, ajouter :
it('does not render add button by default', () => {
const wrapper = mountComponent()
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(false)
})
it('renders add button when addable is true', () => {
const wrapper = mountComponent({addable: true})
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(true)
})
it('emits add event when add button is clicked', async () => {
const wrapper = mountComponent({addable: true})
await wrapper.get('[data-test="add-button"]').trigger('click')
expect(wrapper.emitted('add')).toHaveLength(1)
})
it('does not emit add when disabled', async () => {
const wrapper = mountComponent({addable: true, disabled: true})
await wrapper.get('[data-test="add-button"]').trigger('click')
expect(wrapper.emitted('add')).toBeUndefined()
})
it('does not emit add when readonly', async () => {
const wrapper = mountComponent({addable: true, readonly: true})
await wrapper.get('[data-test="add-button"]').trigger('click')
expect(wrapper.emitted('add')).toBeUndefined()
})
it('disables add button when disabled', () => {
const wrapper = mountComponent({addable: true, disabled: true})
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeDefined()
})
it('add button is not natively disabled in readonly (onAdd guard blocks the action)', () => {
const wrapper = mountComponent({addable: true, readonly: true})
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeUndefined()
})
it('moves the email icon to the left automatically when addable', () => {
const wrapper = mountComponent({addable: true})
const icon = wrapper.get('[data-test="icon"]')
expect(icon.classes()).toContain('left-[10px]')
expect(icon.classes()).not.toContain('right-[10px]')
})
it('keeps the email icon on the right when addable is false', () => {
const wrapper = mountComponent()
expect(wrapper.get('[data-test="icon"]').classes()).toContain('right-[10px]')
})
it('uses the default add button aria-label', () => {
const wrapper = mountComponent({addable: true})
expect(wrapper.get('[data-test="add-button"]').attributes('aria-label')).toBe('Ajouter une adresse email')
})
it('allows overriding the add button aria-label', () => {
const wrapper = mountComponent({addable: true, addButtonLabel: 'Ajouter un destinataire'})
expect(wrapper.get('[data-test="add-button"]').attributes('aria-label')).toBe('Ajouter un destinataire')
})
- Step 3 : Lancer les tests
Run : npm run test -- InputEmail.test.ts
Expected : PASS — tests existants + 11 nouveaux.
Si le test moves the email icon to the left échoue parce que get('[data-test="icon"]') trouve plusieurs éléments, c'est que le stub du bouton-icône a rendu data-test="icon" au lieu de add-icon ; debug en loggant wrapper.findAll('[data-test="icon"]').length. Ne PAS affaiblir l'assertion sans comprendre : data-test="add-icon" doit primer via v-bind="$attrs".
- Step 4 : Commit
git add app/components/malio/input/InputEmail.test.ts
git commit -m "test(email) : couvre le bouton + d'ajout de MalioInputEmail"
Task 3 : Documentation (COMPONENTS.md + CHANGELOG.md)
Files:
-
Modify:
COMPONENTS.md -
Modify:
CHANGELOG.md -
Step 1 : Ajouter les props au tableau
MalioInputEmail
Dans COMPONENTS.md, section ## MalioInputEmail, dans le tableau des props, insérer ces lignes juste après la ligne | \iconColor` | `string` | `'text-m-muted'` | Classe couleur icône |` :
| `addable` | `boolean` | `false` | Affiche un bouton `+` à droite qui émet l'event `add` (l'icône email passe à gauche) |
| `addIconName` | `string` | `'mdi:plus'` | Icône Iconify du bouton d'ajout |
| `addButtonLabel` | `string` | `'Ajouter une adresse email'` | aria-label du bouton d'ajout |
- Step 2 : Documenter l'event
addet ajouter un exemple
Dans la même section, remplacer la ligne :
**Events :** `update:modelValue(value: string)`
par :
**Events :**
- `update:modelValue(value: string)`
- `add()` — émis au clic du bouton `+` (uniquement si `addable`, non `disabled`, non `readonly`)
Puis, dans le bloc d'exemple vue de cette section, ajouter cette ligne juste avant la fence fermante :
<MalioInputEmail v-model="email" label="Email" addable @add="addEmailField" />
- Step 3 : Ajouter l'entrée CHANGELOG
Dans CHANGELOG.md, sous ### Added, ajouter comme dernière puce de la liste (juste après * [#MUI-41] InputEmail : sanitisation à la saisie ...) :
* InputEmail : bouton `+` d'ajout optionnel (prop `addable`, event `add`), calqué sur InputPhone ; l'icône email passe à gauche quand le bouton est actif
- Step 4 : Commit
git add COMPONENTS.md CHANGELOG.md
git commit -m "docs(email) : documente le bouton + d'ajout de MalioInputEmail"
Task 4 : Story + playground
Files:
-
Modify:
app/story/input/inputEmail.story.vue -
Modify:
.playground/pages/composant/input/inputEmail.vue -
Step 1 : Ajouter une carte « addable » dans la story
Dans app/story/input/inputEmail.story.vue, juste après la carte « Icône à gauche » (le <div class="rounded-lg border p-4"> qui se termine ligne 19, contenant icon-position="left") et avant la carte « Sans icône », insérer :
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec bouton « ajouter »</h2>
<MalioInputEmail
v-model="addableValue"
label="Adresse email"
addable
@add="onAdd"
/>
<p v-if="addClicks > 0" class="mt-2 text-sm text-m-muted">
Bouton cliqué {{ addClicks }} fois
</p>
</div>
- Step 2 : Déclarer les refs/handler dans le
<script setup>de la story
Dans le <script setup> de app/story/input/inputEmail.story.vue, après la ligne const simpleValue = ref(''), ajouter :
const addableValue = ref('')
const addClicks = ref(0)
const onAdd = () => { addClicks.value += 1 }
- Step 3 : Ajouter un exemple d'ajout dynamique dans le playground
Dans .playground/pages/composant/input/inputEmail.vue, juste après la carte « Avec label » (le <div class="rounded-lg border p-4"> qui se termine ligne 15) et avant la carte « Icône à gauche », insérer :
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Ajout dynamique (bouton +)</h2>
<div class="space-y-3">
<MalioInputEmail
v-for="(email, index) in emails"
:key="index"
v-model="emails[index]"
label="Adresse email"
addable
@add="emails.push('')"
/>
</div>
</div>
- Step 4 : Déclarer la ref dans le
<script setup>du playground
Dans le <script setup> de .playground/pages/composant/input/inputEmail.vue, après la ligne const emailValue = ref(''), ajouter :
const emails = ref<string[]>([''])
- Step 5 : Vérifier le lint
Run : npm run lint
Expected : 0 erreur sur les deux fichiers modifiés (des warnings pré-existants sur d'AUTRES fichiers sont tolérés).
- Step 6 : Commit
git add app/story/input/inputEmail.story.vue .playground/pages/composant/input/inputEmail.vue
git commit -m "docs(email) : exemples bouton + d'ajout (story + playground)"
Task 5 : Vérification finale
- Step 1 : Suite InputEmail
Run : npm run test -- InputEmail.test.ts
Expected : PASS (existants + 11 nouveaux).
- Step 2 : Lint global
Run : npm run lint
Expected : 0 erreur.
- Step 3 : Vérification manuelle (recommandée)
Run : npm run dev, ouvrir composant/input/inputEmail.
Vérifier :
- Carte « Ajout dynamique » : cliquer « + » ajoute un nouveau champ email en dessous.
- Avec
addable, l'icône email est à gauche et le « + » à droite, sans chevauchement. - Le bouton « + » est grisé/inactif en
disabled. - Les autres cartes email (sans
addable) sont inchangées (icône à droite).
Self-Review
Spec coverage :
- Props
addable/addIconName/addButtonLabel(défautsfalse/'mdi:plus'/'Ajouter une adresse email') → Task 1 Step 1. - Event
add→ Task 1 Step 2. effectiveIconPosition(icône à gauche si addable) + 4 computeds repointées → Task 1 Steps 4-5.iconInputPaddingClassaligné Phone (pr-10 si addable) → Task 1 Step 4.- Bouton template +
mergedAddButtonClass+onAdd(garde disabled/readonly) → Task 1 Steps 3, 6, 7. - Logique email existante intacte (
onInput/sanitizeEmail/lowercasenon touchés) → aucune tâche ne les modifie. - Tests (présence, émission, gardes disabled/readonly, repositionnement icône, libellé) → Task 2.
- Docs COMPONENTS.md + CHANGELOG.md → Task 3 ; story + playground → Task 4.
Placeholder scan : aucun TODO/TBD ; tout le code est fourni intégralement.
Type consistency : addable/addIconName/addButtonLabel (props), add (event), onAdd/effectiveIconPosition/mergedAddButtonClass/iconStateClass (composant) — noms cohérents entre tâches. Les data-test (add-button, add-icon, icon) concordent entre composant (Task 1) et tests (Task 2). iconStateClass et twMerge existent déjà dans InputEmail.vue.