docs(email) : plan implémentation bouton + d'ajout

This commit is contained in:
2026-06-09 12:46:17 +02:00
parent f926ae830c
commit 1296f62ccf
@@ -0,0 +1,458 @@
# 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` :
```ts
addable?: boolean
addIconName?: string
addButtonLabel?: string
```
Dans `withDefaults(..., { ... })`, ajouter juste après `iconColor: 'text-m-muted',` :
```ts
addable: false,
addIconName: 'mdi:plus',
addButtonLabel: 'Ajouter une adresse email',
```
- [ ] **Step 2 : Ajouter l'event `add`**
Remplacer :
```ts
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
}>()
```
par :
```ts
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 :
```ts
const onAdd = () => {
if (props.disabled || props.readonly) return
emit('add')
}
```
- [ ] **Step 4 : Ajouter `effectiveIconPosition` et réécrire `iconInputPaddingClass`**
Remplacer le bloc actuel :
```ts
const iconInputPaddingClass = computed(() => {
if (!props.iconName) return ''
return props.iconPosition === 'left' ? '!pl-11 !pr-3' : '!pl-3 !pr-10'
})
```
par :
```ts
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 :
```ts
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 :
```ts
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 :
```ts
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 :
```html
<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**
```bash
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` :
```ts
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 :
```ts
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**
```bash
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 |` :
```markdown
| `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 :
```markdown
**Events :** `update:modelValue(value: string)`
```
par :
```markdown
**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 ``` :
```vue
<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 ...`) :
```markdown
* 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**
```bash
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 :
```html
<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 :
```ts
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 :
```html
<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 :
```ts
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**
```bash
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`.