Files
malio-layer-ui/docs/superpowers/plans/2026-06-09-inputemail-bouton-ajout.md
T

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 — props addable/addIconName/addButtonLabel, event add, effectiveIconPosition, 4 computeds repointées, bouton + handler onAdd + 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 effectiveIconPosition et réécrire iconInputPaddingClass

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, iconPositionClass sur effectiveIconPosition

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 add et 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éfauts false/'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.
  • iconInputPaddingClass aligné 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/lowercase non 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.