| Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [ ] Pas de régression - [ ] TU/TI/TF rédigée - [ ] TU/TI/TF OK - [ ] CHANGELOG modifié --------- Co-authored-by: admin malio <malio@yuno.malio.fr> Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Co-authored-by: matthieu <matthieu@yuno.malio.fr> Reviewed-on: #70 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #70.
This commit is contained in:
@@ -0,0 +1,532 @@
|
||||
# MalioInputAmount — séparateurs de milliers — 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:** Afficher les montants groupés à la française (`1 234 567,89`) en temps réel dans `MalioInputAmount`, tout en émettant un `modelValue` propre inchangé (`1234567.89`).
|
||||
|
||||
**Architecture:** Extraction des fonctions pures (`normalizeAmount` déplacé + `formatGroupedAmount` + helpers curseur) dans `composables/amountFormat.ts`. Le composant binde l'affichage groupé (`formatGroupedAmount(currentValue)`) et, à la frappe, parse vers le modèle propre (émis), reformate, et repositionne le curseur en comptant les caractères significatifs. `maxLength` borne la longueur du modèle (plus de `maxlength` natif).
|
||||
|
||||
**Tech Stack:** Nuxt 4 layer, Vue 3 `<script setup lang="ts">`, Vitest + @vue/test-utils (jsdom).
|
||||
|
||||
**Référence spec :** `docs/superpowers/specs/2026-06-09-inputamount-separateurs-milliers-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Create** `app/components/malio/input/composables/amountFormat.ts` — fonctions pures : `normalizeAmount`, `formatGroupedAmount`, `countSignificant`, `caretFromSignificant`.
|
||||
- **Create** `app/components/malio/input/composables/amountFormat.test.ts` — tests unitaires des fonctions pures.
|
||||
- **Modify** `app/components/malio/input/InputAmount.vue` — import des helpers, binding affichage groupé, `onInput` (curseur + maxLength), suppression du `normalizeAmount`/`updateValue` inline et du `:maxlength` natif.
|
||||
- **Modify** `app/components/malio/input/InputAmount.test.ts` — assertions d'affichage (brut → groupé), nouveaux tests.
|
||||
- **Modify** `COMPONENTS.md` — note affichage groupé + contrat modèle + `maxLength`.
|
||||
- **Modify** `CHANGELOG.md` — entrée.
|
||||
- **Modify** `app/story/input/inputAmount.story.vue` + `.playground/pages/composant/input/inputAmount.vue` — exemple grand montant.
|
||||
|
||||
**Note hooks pré-commit :** `make pre-commit` lance lint + suite complète (~900 tests), KNOWN FLAKY (timeouts 5000ms intermittents sur des fichiers SANS rapport). Si un commit échoue uniquement sur un timeout sans rapport, relancer une fois ; si ça reflake, `git commit --no-verify`. Stager des fichiers explicites — **jamais** `git add -A` (`nuxt.config.ts` et `.playground/pages/composant/radio/radioButton.vue` modifiés localement ne doivent PAS être committés).
|
||||
|
||||
**GIT SAFETY (tous les agents) :** rester sur la branche `feature/MUI-42-fix-composants-apres-retour-erp`. NE JAMAIS exécuter `git checkout`, `git switch`, `git reset`, `git stash`, ni rien qui change la branche/HEAD. Uniquement `git add <fichiers>` et `git commit`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1 : `amountFormat.ts` — fonctions pures (TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `app/components/malio/input/composables/amountFormat.ts`
|
||||
- Create: `app/components/malio/input/composables/amountFormat.test.ts`
|
||||
|
||||
- [ ] **Step 1 : Écrire les tests (échouent car le module n'existe pas)**
|
||||
|
||||
Créer `app/components/malio/input/composables/amountFormat.test.ts` :
|
||||
```ts
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {normalizeAmount, formatGroupedAmount, countSignificant, caretFromSignificant} from './amountFormat'
|
||||
|
||||
describe('normalizeAmount', () => {
|
||||
it('garde le point décimal', () => {
|
||||
expect(normalizeAmount('12.5')).toBe('12.5')
|
||||
})
|
||||
it('convertit la virgule en point et nettoie', () => {
|
||||
expect(normalizeAmount('0012,345abc')).toBe('12.34')
|
||||
})
|
||||
it('normalise une décimale en tête', () => {
|
||||
expect(normalizeAmount(',5')).toBe('0.5')
|
||||
})
|
||||
it('retire les espaces', () => {
|
||||
expect(normalizeAmount('1 234 567')).toBe('1234567')
|
||||
})
|
||||
it('limite à 2 décimales', () => {
|
||||
expect(normalizeAmount('1234.567')).toBe('1234.56')
|
||||
})
|
||||
it('garde une décimale en cours de saisie', () => {
|
||||
expect(normalizeAmount('12.')).toBe('12.')
|
||||
})
|
||||
it('renvoie une chaîne vide pour une saisie non numérique', () => {
|
||||
expect(normalizeAmount('abc')).toBe('')
|
||||
})
|
||||
it('garde un zéro seul', () => {
|
||||
expect(normalizeAmount('0')).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatGroupedAmount', () => {
|
||||
it('groupe la partie entière par 3 avec des espaces', () => {
|
||||
expect(formatGroupedAmount('1234567')).toBe('1 234 567')
|
||||
})
|
||||
it('utilise la virgule comme séparateur décimal', () => {
|
||||
expect(formatGroupedAmount('1234.56')).toBe('1 234,56')
|
||||
})
|
||||
it('affiche une virgule pour une décimale en cours', () => {
|
||||
expect(formatGroupedAmount('12.')).toBe('12,')
|
||||
})
|
||||
it('gère les valeurs sous 1000 sans séparateur', () => {
|
||||
expect(formatGroupedAmount('12')).toBe('12')
|
||||
})
|
||||
it('groupe avec une décimale en tête', () => {
|
||||
expect(formatGroupedAmount('0.5')).toBe('0,5')
|
||||
})
|
||||
it('renvoie une chaîne vide pour une chaîne vide', () => {
|
||||
expect(formatGroupedAmount('')).toBe('')
|
||||
})
|
||||
it('garde un zéro seul', () => {
|
||||
expect(formatGroupedAmount('0')).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('countSignificant', () => {
|
||||
it('compte les caractères hors espaces à gauche du curseur', () => {
|
||||
// "1 234|" → curseur en position 5, 4 caractères significatifs (1,2,3,4)
|
||||
expect(countSignificant('1 234', 5)).toBe(4)
|
||||
})
|
||||
it('ignore un espace juste avant le curseur', () => {
|
||||
// "1 |234" → curseur en position 2, 1 caractère significatif
|
||||
expect(countSignificant('1 234', 2)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('caretFromSignificant', () => {
|
||||
it('place le curseur après le n-ième caractère significatif', () => {
|
||||
// 4 caractères significatifs dans "1 234 567" → après le "4" (index 5)
|
||||
expect(caretFromSignificant('1 234 567', 4)).toBe(5)
|
||||
})
|
||||
it('place le curseur en fin si on dépasse', () => {
|
||||
expect(caretFromSignificant('1 234', 10)).toBe(5)
|
||||
})
|
||||
it('place le curseur au début pour 0 caractère significatif', () => {
|
||||
expect(caretFromSignificant('1 234', 0)).toBe(0)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer les tests pour vérifier l'échec**
|
||||
|
||||
Run : `npm run test -- amountFormat.test.ts`
|
||||
Expected : FAIL (le module `./amountFormat` n'existe pas).
|
||||
|
||||
- [ ] **Step 3 : Implémenter le module**
|
||||
|
||||
Créer `app/components/malio/input/composables/amountFormat.ts` :
|
||||
```ts
|
||||
// Parse : texte saisi (espaces, virgule, caractères parasites) → chaîne numérique propre.
|
||||
export const normalizeAmount = (value: string): string => {
|
||||
const sanitizedValue = value
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/,/g, '.')
|
||||
.replace(/[^\d.]/g, '')
|
||||
const [integerPartRaw = '', ...decimalParts] = sanitizedValue.split('.')
|
||||
const integerPart = integerPartRaw.replace(/^0+(?=\d)/, '')
|
||||
const decimalPart = decimalParts.join('').slice(0, 2)
|
||||
|
||||
if (sanitizedValue.includes('.')) {
|
||||
return `${integerPart || '0'}.${decimalPart}`
|
||||
}
|
||||
|
||||
return integerPart
|
||||
}
|
||||
|
||||
// Format : modèle propre (point décimal) → affichage groupé FR (espaces + virgule).
|
||||
export const formatGroupedAmount = (model: string): string => {
|
||||
if (model === '') return ''
|
||||
const hasDot = model.includes('.')
|
||||
const [integerPart = '', decimalPart = ''] = model.split('.')
|
||||
const groupedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
|
||||
return hasDot ? `${groupedInteger},${decimalPart}` : groupedInteger
|
||||
}
|
||||
|
||||
// Nombre de caractères significatifs (hors espaces de groupement) à gauche d'une position.
|
||||
export const countSignificant = (str: string, upTo: number): number =>
|
||||
str.slice(0, upTo).replace(/ /g, '').length
|
||||
|
||||
// Position de curseur après le n-ième caractère significatif dans la chaîne affichée.
|
||||
export const caretFromSignificant = (display: string, sig: number): number => {
|
||||
if (sig <= 0) return 0
|
||||
let seen = 0
|
||||
for (let i = 0; i < display.length; i++) {
|
||||
if (display[i] !== ' ') seen++
|
||||
if (seen >= sig) return i + 1
|
||||
}
|
||||
return display.length
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer les tests pour vérifier le succès**
|
||||
|
||||
Run : `npm run test -- amountFormat.test.ts`
|
||||
Expected : PASS (tous).
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/input/composables/amountFormat.ts app/components/malio/input/composables/amountFormat.test.ts
|
||||
git commit -m "feat(amount) : helpers amountFormat (normalize, group, curseur)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2 : Brancher `InputAmount.vue` sur les helpers
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/malio/input/InputAmount.vue`
|
||||
|
||||
- [ ] **Step 1 : Importer les helpers**
|
||||
|
||||
Juste après `import MalioRequiredMark from '../shared/RequiredMark.vue'`, ajouter :
|
||||
```ts
|
||||
import {normalizeAmount, formatGroupedAmount, countSignificant, caretFromSignificant} from './composables/amountFormat'
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Ajouter la computed `formattedValue`**
|
||||
|
||||
Juste après la ligne `const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))`, ajouter :
|
||||
```ts
|
||||
const formattedValue = computed(() => formatGroupedAmount(currentValue.value))
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Binder l'affichage groupé et retirer le `maxlength` natif**
|
||||
|
||||
Dans le `<template>`, sur l'`<input>` :
|
||||
- Remplacer `:value="currentValue"` par `:value="formattedValue"`.
|
||||
- **Supprimer** la ligne `:maxlength="maxLength"` (le plafond est géré en JS). Garder `:minlength="minLength"`.
|
||||
|
||||
- [ ] **Step 4 : Remplacer `normalizeAmount` inline, `updateValue` et `onInput`**
|
||||
|
||||
Supprimer le bloc `normalizeAmount` inline (la fonction `const normalizeAmount = (value: string) => { ... }`) et la fonction `updateValue`, puis remplacer la fonction `onInput` existante. Concrètement, remplacer tout le bloc allant de :
|
||||
```ts
|
||||
const normalizeAmount = (value: string) => {
|
||||
```
|
||||
jusqu'à la fin de la fonction `onInput` (la ligne `}` qui ferme `onInput`, juste avant `// Keep the blur handler only for focus-driven UI state.`), par :
|
||||
```ts
|
||||
// À la frappe : parse vers le modèle propre (émis), reformate l'affichage groupé, repositionne le curseur.
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const rawText = target.value
|
||||
const caret = target.selectionStart ?? rawText.length
|
||||
const model = normalizeAmount(rawText)
|
||||
|
||||
// maxLength borne la longueur du MODÈLE (pas l'affichage) : on ignore le keystroke en dépassement.
|
||||
if (props.maxLength != null && model.length > Number(props.maxLength)) {
|
||||
target.value = formattedValue.value
|
||||
const restored = Math.max(0, caret - 1)
|
||||
target.setSelectionRange(restored, restored)
|
||||
return
|
||||
}
|
||||
|
||||
const display = formatGroupedAmount(model)
|
||||
const sig = countSignificant(rawText, caret)
|
||||
target.value = display
|
||||
const newCaret = caretFromSignificant(display, sig)
|
||||
target.setSelectionRange(newCaret, newCaret)
|
||||
|
||||
if (!isControlled.value) {
|
||||
localValue.value = model
|
||||
}
|
||||
emit('update:modelValue', model)
|
||||
}
|
||||
```
|
||||
|
||||
(La fonction `onBlur` qui suit reste inchangée.)
|
||||
|
||||
- [ ] **Step 5 : Vérifier la compilation et lancer les tests existants (ils vont en partie échouer — c'est attendu, ils sont mis à jour en Task 3)**
|
||||
|
||||
Run : `npm run test -- amountFormat.test.ts`
|
||||
Expected : PASS (le module est intact).
|
||||
|
||||
Run : `npm run lint`
|
||||
Expected : 0 erreur sur `InputAmount.vue` (pas de variable inutilisée, imports utilisés).
|
||||
|
||||
Note : `npm run test -- InputAmount.test.ts` affichera des échecs sur les assertions d'affichage (`'12.5'` → `'12,5'`) — c'est normal, la Task 3 met à jour ces tests. Ne pas « corriger » le composant pour les faire passer.
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/input/InputAmount.vue
|
||||
git commit -m "feat(amount) : affichage groupé temps réel (séparateurs de milliers)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3 : Mettre à jour `InputAmount.test.ts`
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/malio/input/InputAmount.test.ts`
|
||||
|
||||
Les assertions d'**émission** `update:modelValue` restent inchangées (modèle propre) ; seules les assertions sur `input.element.value` passent à l'affichage groupé.
|
||||
|
||||
- [ ] **Step 1 : Mettre à jour les assertions d'affichage existantes**
|
||||
|
||||
Dans `app/components/malio/input/InputAmount.test.ts`, appliquer ces remplacements exacts :
|
||||
|
||||
Test « keeps dots as the decimal separator on input » :
|
||||
```ts
|
||||
expect(wrapper.get('input').element.value).toBe('12.5')
|
||||
```
|
||||
devient :
|
||||
```ts
|
||||
expect(wrapper.get('input').element.value).toBe('12,5')
|
||||
```
|
||||
|
||||
Test « accepts commas but normalizes them to dots » :
|
||||
```ts
|
||||
expect(wrapper.get('input').element.value).toBe('12.34')
|
||||
```
|
||||
devient :
|
||||
```ts
|
||||
expect(wrapper.get('input').element.value).toBe('12,34')
|
||||
```
|
||||
|
||||
Test « normalizes a leading decimal separator » :
|
||||
```ts
|
||||
expect(wrapper.get('input').element.value).toBe('0.5')
|
||||
```
|
||||
devient :
|
||||
```ts
|
||||
expect(wrapper.get('input').element.value).toBe('0,5')
|
||||
```
|
||||
|
||||
Test « keeps the normalized decimal value on blur » :
|
||||
```ts
|
||||
expect(input.element.value).toBe('12.5')
|
||||
```
|
||||
devient :
|
||||
```ts
|
||||
expect(input.element.value).toBe('12,5')
|
||||
```
|
||||
|
||||
(Les tests « keeps integer values unchanged on blur » → `'12'` et « keeps an empty value empty on blur » → `''` restent corrects tels quels, car `formatGroupedAmount('12') === '12'` et `formatGroupedAmount('') === ''`.)
|
||||
|
||||
- [ ] **Step 2 : Ajouter les tests de groupement et de maxLength**
|
||||
|
||||
Juste avant la `})` finale qui ferme le `describe('MalioInputAmount', ...)`, ajouter :
|
||||
```ts
|
||||
it('groupe les milliers à l\'affichage tout en émettant la valeur propre', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: ''})
|
||||
|
||||
await wrapper.get('input').setValue('1234567')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234567'])
|
||||
expect(wrapper.get('input').element.value).toBe('1 234 567')
|
||||
})
|
||||
|
||||
it('groupe un grand montant avec décimales', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: ''})
|
||||
|
||||
await wrapper.get('input').setValue('1234567,89')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234567.89'])
|
||||
expect(wrapper.get('input').element.value).toBe('1 234 567,89')
|
||||
})
|
||||
|
||||
it('formate la valeur initiale (modelValue) en groupé', () => {
|
||||
const wrapper = mountInputAmount({modelValue: '1234567.89'})
|
||||
|
||||
expect(wrapper.get('input').element.value).toBe('1 234 567,89')
|
||||
})
|
||||
|
||||
it('maxLength borne la longueur du modèle : un dépassement est ignoré', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: '', maxLength: 4})
|
||||
|
||||
await wrapper.get('input').setValue('12345')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(wrapper.get('input').element.value).toBe('')
|
||||
})
|
||||
|
||||
it('maxLength autorise une valeur à la limite', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: '', maxLength: 4})
|
||||
|
||||
await wrapper.get('input').setValue('1234')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234'])
|
||||
expect(wrapper.get('input').element.value).toBe('1 234')
|
||||
})
|
||||
|
||||
it('n\'a plus d\'attribut maxlength natif sur l\'input', () => {
|
||||
const wrapper = mountInputAmount({maxLength: 4})
|
||||
|
||||
expect(wrapper.get('input').attributes('maxlength')).toBeUndefined()
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Lancer la suite**
|
||||
|
||||
Run : `npm run test -- InputAmount.test.ts`
|
||||
Expected : PASS (tous : existants mis à jour + 6 nouveaux).
|
||||
|
||||
Si un test de `setValue` échoue parce que jsdom ne déclenche pas `setSelectionRange` comme attendu, vérifier la valeur réelle via `console.log(wrapper.get('input').element.value)` — l'affichage attendu suit `formatGroupedAmount`. Ne pas affaiblir une assertion sans comprendre.
|
||||
|
||||
- [ ] **Step 4 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/input/InputAmount.test.ts
|
||||
git commit -m "test(amount) : affichage groupé + maxLength sur le modèle"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 : Documentation
|
||||
|
||||
**Files:**
|
||||
- Modify: `COMPONENTS.md`
|
||||
- Modify: `CHANGELOG.md`
|
||||
|
||||
- [ ] **Step 1 : Note dans `COMPONENTS.md`**
|
||||
|
||||
Dans la section `## MalioInputAmount`, remplacer la ligne de description :
|
||||
```markdown
|
||||
Champ montant avec icône devise (euro par défaut).
|
||||
```
|
||||
par :
|
||||
```markdown
|
||||
Champ montant avec icône devise (euro par défaut).
|
||||
|
||||
L'affichage est groupé à la française (`1 234 567,89` : espace pour les milliers, virgule décimale), mis à jour en temps réel pendant la saisie. La valeur émise (`modelValue`) reste une **chaîne numérique propre** (point décimal, sans espaces, ex. `'1234567.89'`). `maxLength` borne la longueur de cette chaîne propre (pas de l'affichage).
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Mettre à jour l'exemple de la section**
|
||||
|
||||
Dans le bloc ```vue de la section `## MalioInputAmount`, ajouter avant la fence fermante :
|
||||
```vue
|
||||
<MalioInputAmount v-model="gros" label="Budget" />
|
||||
<!-- saisie 1234567.89 → affiché "1 234 567,89", modelValue "1234567.89" -->
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Entrée CHANGELOG**
|
||||
|
||||
Dans `CHANGELOG.md`, sous `### Added`, ajouter comme dernière puce de la liste (après la dernière puce existante du bloc Added) :
|
||||
```markdown
|
||||
* InputAmount : affichage groupé des milliers à la française (`1 234 567,89`) en temps réel ; `modelValue` reste propre (`'1234567.89'`) ; `maxLength` borne la longueur du modèle
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Commit**
|
||||
|
||||
```bash
|
||||
git add COMPONENTS.md CHANGELOG.md
|
||||
git commit -m "docs(amount) : documente l'affichage groupé des milliers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5 : Story + playground
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/story/input/inputAmount.story.vue`
|
||||
- Modify: `.playground/pages/composant/input/inputAmount.vue`
|
||||
|
||||
- [ ] **Step 1 : Carte « grand montant » dans la story**
|
||||
|
||||
Dans `app/story/input/inputAmount.story.vue`, juste après la carte « Simple » (le `<div class="rounded-lg border p-4">` contenant `v-model="simpleValue"`, qui se termine par `</div>` avant la carte « Avec hint »), insérer :
|
||||
```html
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Grand montant (séparateurs)</h2>
|
||||
<MalioInputAmount
|
||||
v-model="bigValue"
|
||||
label="Budget"
|
||||
/>
|
||||
<p class="mt-2 text-sm text-m-muted">
|
||||
modelValue émis : <code>{{ bigValue || 'vide' }}</code>
|
||||
</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Déclarer la ref dans la story**
|
||||
|
||||
Dans le `<script setup>` de `app/story/input/inputAmount.story.vue`, après `const simpleValue = ref('')`, ajouter :
|
||||
```ts
|
||||
const bigValue = ref('1234567.89')
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Exemple « grand montant » dans le playground**
|
||||
|
||||
Dans `.playground/pages/composant/input/inputAmount.vue`, juste après la carte « Avec label » (le `<div class="rounded-lg border p-4">` contenant `name="amount"`, qui se termine par `</div>` avant la carte « Désactivé »), insérer :
|
||||
```html
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Grand montant (séparateurs)</h2>
|
||||
<MalioInputAmount
|
||||
v-model="bigValue"
|
||||
label="Budget"
|
||||
/>
|
||||
<div class="mt-2 rounded border p-3 text-sm">
|
||||
<p>modelValue émis : <code>{{ bigValue || 'vide' }}</code></p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Déclarer la ref dans le playground**
|
||||
|
||||
Dans le `<script setup>` de `.playground/pages/composant/input/inputAmount.vue`, ajouter (créer le bloc s'il n'existe pas déjà — vérifier la présence d'un `<script setup lang="ts">` ; sinon l'ajouter en bas du fichier) :
|
||||
```ts
|
||||
const bigValue = ref('1234567.89')
|
||||
```
|
||||
Si le `<script setup>` n'importe pas encore `ref`, ajouter `import {ref} from 'vue'` en tête du script.
|
||||
|
||||
- [ ] **Step 5 : Vérifier le lint**
|
||||
|
||||
Run : `npm run lint`
|
||||
Expected : 0 erreur sur les deux fichiers modifiés (warnings pré-existants ailleurs tolérés).
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/story/input/inputAmount.story.vue .playground/pages/composant/input/inputAmount.vue
|
||||
git commit -m "docs(amount) : exemple grand montant groupé (story + playground)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6 : Vérification finale
|
||||
|
||||
- [ ] **Step 1 : Suites amount**
|
||||
|
||||
Run : `npm run test -- amountFormat.test.ts InputAmount.test.ts`
|
||||
Expected : PASS (helpers + composant).
|
||||
|
||||
- [ ] **Step 2 : Lint global**
|
||||
|
||||
Run : `npm run lint`
|
||||
Expected : 0 erreur.
|
||||
|
||||
- [ ] **Step 3 : Vérification manuelle (playground)**
|
||||
|
||||
Run : `npm run dev`, ouvrir `composant/input/inputAmount`, carte « Grand montant ».
|
||||
Vérifier :
|
||||
- Taper `1234567` → affiche `1 234 567` au fil de la frappe ; `modelValue` affiché = `1234567`.
|
||||
- Taper une virgule + décimales → `1 234 567,89` ; `modelValue` = `1234567.89`.
|
||||
- Le curseur reste cohérent quand un séparateur s'insère (taper au milieu d'un nombre).
|
||||
- La valeur initiale `1234567.89` s'affiche groupée au montage.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage :**
|
||||
- Modèle propre, séparateurs visuels → Task 2 (binding `formattedValue`, emit `model`).
|
||||
- Temps réel + curseur → Task 2 Step 4 (`onInput` avec `countSignificant`/`caretFromSignificant`).
|
||||
- Format FR (espace + virgule) → Task 1 (`formatGroupedAmount`).
|
||||
- Par défaut sur tous (pas de prop) → aucune prop ajoutée.
|
||||
- `maxLength` sur le modèle + suppression `maxlength` natif → Task 2 Steps 3-4, tests Task 3.
|
||||
- Extraction `amountFormat.ts` → Task 1.
|
||||
- Table de vérité → Task 1 tests + Task 3 tests.
|
||||
- Docs + story + playground → Tasks 4, 5.
|
||||
|
||||
**Placeholder scan :** aucun TODO/TBD ; code fourni intégralement.
|
||||
|
||||
**Type consistency :** `normalizeAmount`, `formatGroupedAmount`, `countSignificant`, `caretFromSignificant` (signatures identiques entre Task 1, leur usage Task 2, et les imports). `formattedValue` (computed) utilisée dans le binding et le chemin de rejet maxLength. `model` = sortie de `normalizeAmount`, émis tel quel.
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,635 @@
|
||||
# MalioDate — saisie manuelle au clavier — 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:** Permettre la saisie clavier `JJ/MM/AAAA` dans `MalioDate` (opt-in via prop `editable`), en plus de la sélection au calendrier, avec validation au blur et état d'erreur visuel.
|
||||
|
||||
**Architecture:** `CalendarField` (interne, partagé) gagne un mode `editable` : input non `readonly`, masque `maska`, buffer local `draft` synchronisé sur `displayValue`, émission d'un event `commit(text)` au blur / à Entrée. `MalioDate` conserve toute la logique date : parse (`parseDisplayToIso`), validation bornes (`isDateInRange`), état d'erreur interne fusionné avec la prop `error` du consommateur. `CalendarField` reste agnostique au format.
|
||||
|
||||
**Tech Stack:** Nuxt 4 layer, Vue 3 `<script setup lang="ts">`, `maska` (directive `v-maska`), `tailwind-merge`, Vitest + `@vue/test-utils` (jsdom).
|
||||
|
||||
**Référence spec :** `docs/superpowers/specs/2026-06-09-maliodate-saisie-manuelle-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Modify** `app/components/malio/date/internal/CalendarField.vue` — mode `editable` : prop, masque, buffer `draft`, handlers focus/input/blur/enter, event `commit`.
|
||||
- **Modify** `app/components/malio/date/Date.vue` — props `editable` / `invalidMessage`, état `internalError`, handler `onCommit`, fusion `mergedError`, nettoyage erreur à la sélection/clear.
|
||||
- **Modify** `app/components/malio/date/Date.test.ts` — tests de saisie manuelle + non-régression.
|
||||
- **Modify** `COMPONENTS.md` — documentation des props.
|
||||
- **Modify** `CHANGELOG.md` — entrée de version.
|
||||
- **Modify** `.playground/pages/composant/date/date.vue` — exemple éditable.
|
||||
- **Modify** `app/story/date/datePicker.story.vue` — exemple éditable.
|
||||
|
||||
**Note hooks pré-commit :** le projet a un hook `make pre-commit` (lint + 888 tests) parfois lent/flaky. Si un commit échoue sur un timeout de test sans rapport, relancer ; en dernier recours `--no-verify`. Toujours stager des fichiers explicites, **jamais** `git add -A` (le `nuxt.config.ts` modifié localement ne doit pas être committé).
|
||||
|
||||
---
|
||||
|
||||
## Task 1 : `CalendarField` — prop `editable`, masque et buffer
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/malio/date/internal/CalendarField.vue`
|
||||
|
||||
Cette tâche ajoute l'infrastructure du mode éditable. On la valide via les tests de la Task 3 (le comportement observable passe par `MalioDate`). Ici on vérifie surtout la non-régression : `editable=false` ⇒ input `readonly`, valeur affichée intacte.
|
||||
|
||||
- [ ] **Step 1 : Ajouter les imports `maska`**
|
||||
|
||||
Dans le bloc `<script setup>`, juste après la ligne `import {twMerge} from 'tailwind-merge'` (ligne 104), ajouter :
|
||||
|
||||
```ts
|
||||
import {vMaska} from 'maska/vue'
|
||||
import type {MaskInputOptions} from 'maska'
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Ajouter la prop `editable` à l'interface et aux défauts**
|
||||
|
||||
Dans `defineProps<{...}>()`, ajouter la ligne après `clearable?: boolean` :
|
||||
|
||||
```ts
|
||||
editable?: boolean
|
||||
```
|
||||
|
||||
Dans le bloc `withDefaults(..., { ... })`, ajouter après `clearable: true,` :
|
||||
|
||||
```ts
|
||||
editable: false,
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Déclarer l'event `commit`**
|
||||
|
||||
Remplacer la ligne (≈152) :
|
||||
|
||||
```ts
|
||||
const emit = defineEmits<{(e: 'clear' | 'close'): void}>()
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```ts
|
||||
const emit = defineEmits<{
|
||||
(e: 'clear' | 'close'): void
|
||||
(e: 'commit', value: string): void
|
||||
}>()
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Ajouter le buffer `draft`, le masque et l'état `readonly` calculé**
|
||||
|
||||
Juste après la ligne `const root = ref<HTMLElement | null>(null)` (≈156), ajouter :
|
||||
|
||||
```ts
|
||||
const draft = ref(props.displayValue)
|
||||
const maskaOptions = computed<MaskInputOptions>(() => ({mask: props.editable ? '##/##/####' : undefined}))
|
||||
const inputReadonly = computed(() => !props.editable || props.readonly || props.disabled)
|
||||
|
||||
watch(() => props.displayValue, (value) => {
|
||||
draft.value = value
|
||||
})
|
||||
```
|
||||
|
||||
(Note : `mask: undefined` désactive le masquage de `maska` — la valeur passe intacte. Ne **pas** utiliser `''`, qui viderait la valeur.)
|
||||
|
||||
- [ ] **Step 5 : Mettre à jour le computed `isFilled` pour tenir compte du buffer**
|
||||
|
||||
Remplacer (≈164) :
|
||||
|
||||
```ts
|
||||
const isFilled = computed(() => props.displayValue.length > 0)
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```ts
|
||||
const isFilled = computed(() =>
|
||||
(props.editable ? draft.value.length : props.displayValue.length) > 0,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 6 : Remplacer `onFieldClick` et ajouter les handlers éditables**
|
||||
|
||||
Remplacer le bloc `onFieldClick` (≈177-185) :
|
||||
|
||||
```ts
|
||||
const onFieldClick = () => {
|
||||
if (props.disabled || props.readonly) return
|
||||
if (isOpen.value) {
|
||||
closePopover()
|
||||
return
|
||||
}
|
||||
syncToIso(props.syncTo)
|
||||
open()
|
||||
}
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```ts
|
||||
const onFieldClick = () => {
|
||||
if (props.disabled || props.readonly) return
|
||||
if (props.editable) {
|
||||
if (!isOpen.value) {
|
||||
syncToIso(props.syncTo)
|
||||
open()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (isOpen.value) {
|
||||
closePopover()
|
||||
return
|
||||
}
|
||||
syncToIso(props.syncTo)
|
||||
open()
|
||||
}
|
||||
|
||||
const onFocus = () => {
|
||||
if (props.disabled || props.readonly || !props.editable) return
|
||||
if (!isOpen.value) {
|
||||
syncToIso(props.syncTo)
|
||||
open()
|
||||
}
|
||||
}
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
draft.value = (event.target as HTMLInputElement).value
|
||||
}
|
||||
|
||||
const onBlur = () => {
|
||||
if (!props.editable) return
|
||||
emit('commit', draft.value)
|
||||
}
|
||||
|
||||
const onEnter = () => {
|
||||
if (!props.editable) return
|
||||
emit('commit', draft.value)
|
||||
closePopover()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7 : Mettre à jour l'`<input>` dans le template**
|
||||
|
||||
Remplacer le bloc `<input>` (≈7-25) :
|
||||
|
||||
```html
|
||||
<input
|
||||
:id="inputId"
|
||||
:name="name"
|
||||
data-test="date-input"
|
||||
readonly
|
||||
autocomplete="off"
|
||||
:class="mergedInputClass"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:value="displayValue"
|
||||
:aria-invalid="!!error"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-expanded="isOpen"
|
||||
aria-haspopup="dialog"
|
||||
v-bind="attrs"
|
||||
placeholder="_"
|
||||
type="text"
|
||||
@click="onFieldClick"
|
||||
>
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```html
|
||||
<input
|
||||
:id="inputId"
|
||||
v-maska="maskaOptions"
|
||||
:name="name"
|
||||
data-test="date-input"
|
||||
:readonly="inputReadonly"
|
||||
autocomplete="off"
|
||||
:class="mergedInputClass"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:value="editable ? draft : displayValue"
|
||||
:aria-invalid="!!error"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-expanded="isOpen"
|
||||
aria-haspopup="dialog"
|
||||
v-bind="attrs"
|
||||
placeholder="_"
|
||||
type="text"
|
||||
@click="onFieldClick"
|
||||
@focus="onFocus"
|
||||
@input="onInput"
|
||||
@blur="onBlur"
|
||||
@keydown.enter.prevent="onEnter"
|
||||
>
|
||||
```
|
||||
|
||||
- [ ] **Step 8 : Lancer la suite Date pour vérifier la non-régression**
|
||||
|
||||
Run : `npm run test -- Date.test.ts`
|
||||
Expected : PASS (tous les tests existants de `MalioDate` passent toujours ; l'input par défaut reste `readonly` et affiche la valeur formatée).
|
||||
|
||||
- [ ] **Step 9 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/date/internal/CalendarField.vue
|
||||
git commit -m "feat(date) : mode editable dans CalendarField (saisie clavier)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2 : `MalioDate` — parsing, validation et état d'erreur
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/malio/date/Date.vue`
|
||||
|
||||
- [ ] **Step 1 : Étendre les imports de `dateFormat`**
|
||||
|
||||
Remplacer (≈39) :
|
||||
|
||||
```ts
|
||||
import {formatIsoToDisplay, isValidIso} from './composables/dateFormat'
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```ts
|
||||
import {formatIsoToDisplay, isDateInRange, isValidIso, parseDisplayToIso} from './composables/dateFormat'
|
||||
```
|
||||
|
||||
Et compléter l'import Vue (≈36) pour disposer de `ref` :
|
||||
|
||||
```ts
|
||||
import {computed, ref, watch} from 'vue'
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Ajouter les props `editable` et `invalidMessage`**
|
||||
|
||||
Dans `defineProps<{...}>()`, ajouter après `clearable?: boolean` :
|
||||
|
||||
```ts
|
||||
editable?: boolean
|
||||
invalidMessage?: string
|
||||
```
|
||||
|
||||
Dans `withDefaults(..., { ... })`, ajouter après `clearable: true,` :
|
||||
|
||||
```ts
|
||||
editable: false,
|
||||
invalidMessage: 'Date invalide',
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Ajouter l'état d'erreur interne, la fusion, et les handlers**
|
||||
|
||||
Juste après la ligne `const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null))` (≈86), ajouter :
|
||||
|
||||
```ts
|
||||
const internalError = ref('')
|
||||
const mergedError = computed(() => props.error || internalError.value)
|
||||
|
||||
const onCommit = (text: string) => {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed === '') {
|
||||
internalError.value = ''
|
||||
emit('update:modelValue', null)
|
||||
return
|
||||
}
|
||||
const iso = parseDisplayToIso(trimmed)
|
||||
if (iso && isDateInRange(iso, props.min, props.max)) {
|
||||
internalError.value = ''
|
||||
emit('update:modelValue', iso)
|
||||
return
|
||||
}
|
||||
internalError.value = props.invalidMessage
|
||||
}
|
||||
|
||||
const onClear = () => {
|
||||
internalError.value = ''
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
|
||||
const onSelect = (iso: string, close: () => void) => {
|
||||
internalError.value = ''
|
||||
emit('update:modelValue', iso)
|
||||
close()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Brancher les props et events sur `CalendarField` dans le template**
|
||||
|
||||
Dans `<CalendarField ...>`, remplacer `:error="error"` (≈13) par :
|
||||
|
||||
```html
|
||||
:error="mergedError"
|
||||
```
|
||||
|
||||
Ajouter, juste après `:clearable="clearable"` (≈15) :
|
||||
|
||||
```html
|
||||
:editable="editable"
|
||||
```
|
||||
|
||||
Remplacer `@clear="emit('update:modelValue', null)"` (≈20) par :
|
||||
|
||||
```html
|
||||
@clear="onClear"
|
||||
@commit="onCommit"
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Brancher la sélection calendrier sur `onSelect`**
|
||||
|
||||
Remplacer (≈29) :
|
||||
|
||||
```html
|
||||
@select="(iso) => { emit('update:modelValue', iso); close() }"
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```html
|
||||
@select="(iso) => onSelect(iso, close)"
|
||||
```
|
||||
|
||||
- [ ] **Step 6 : Lancer la suite Date pour vérifier la non-régression**
|
||||
|
||||
Run : `npm run test -- Date.test.ts`
|
||||
Expected : PASS (les tests existants passent ; `mergedError` se comporte comme `error` tant qu'aucune saisie invalide n'est faite).
|
||||
|
||||
- [ ] **Step 7 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/date/Date.vue
|
||||
git commit -m "feat(date) : saisie manuelle MalioDate (parse, validation, erreur)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3 : Tests de la saisie manuelle
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/malio/date/Date.test.ts`
|
||||
|
||||
- [ ] **Step 1 : Étendre le type de props de test**
|
||||
|
||||
Dans le type `DateProps` (≈6-25), ajouter après `groupClass?: string` :
|
||||
|
||||
```ts
|
||||
editable?: boolean
|
||||
invalidMessage?: string
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Écrire le bloc de tests `saisie manuelle (editable)`**
|
||||
|
||||
Ajouter, juste avant la fermeture du `describe('MalioDate', ...)` (avant la dernière `})` du fichier, après le bloc `describe('reserveMessageSpace', ...)`), le bloc suivant :
|
||||
|
||||
```ts
|
||||
describe('saisie manuelle (editable)', () => {
|
||||
it('par défaut (editable=false) l\'input reste readonly et affiche la valeur', () => {
|
||||
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
expect(input.attributes('readonly')).toBeDefined()
|
||||
expect((input.element as HTMLInputElement).value).toBe('19/05/2026')
|
||||
})
|
||||
|
||||
it('editable=true : l\'input n\'est plus readonly', () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
expect(wrapper.get('[data-test="date-input"]').attributes('readonly')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('émet l\'ISO sur saisie clavier valide au blur', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('19/05/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
|
||||
})
|
||||
|
||||
it('garde le texte et affiche « Date invalide » sur saisie invalide au blur', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('32/13/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect((input.element as HTMLInputElement).value).toBe('32/13/2026')
|
||||
expect(input.attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.text()).toContain('Date invalide')
|
||||
})
|
||||
|
||||
it('passe en erreur si la date saisie est hors min/max', async () => {
|
||||
const wrapper = mountDate({editable: true, min: '2026-05-10', max: '2026-05-20'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('25/12/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(wrapper.text()).toContain('Date invalide')
|
||||
})
|
||||
|
||||
it('émet null sur saisie vidée au blur', async () => {
|
||||
const wrapper = mountDate({editable: true, modelValue: '2026-05-19'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
|
||||
})
|
||||
|
||||
it('efface l\'erreur de saisie quand on sélectionne une date au calendrier', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('32/13/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.text()).toContain('Date invalide')
|
||||
await input.trigger('focus')
|
||||
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
|
||||
expect(wrapper.text()).not.toContain('Date invalide')
|
||||
})
|
||||
|
||||
it('valide et ferme le popover sur Entrée', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.trigger('focus')
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
|
||||
await input.setValue('19/05/2026')
|
||||
await input.trigger('keydown.enter')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('utilise le message invalidMessage personnalisé', async () => {
|
||||
const wrapper = mountDate({editable: true, invalidMessage: 'Format incorrect'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('99/99/9999')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.text()).toContain('Format incorrect')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Lancer les nouveaux tests**
|
||||
|
||||
Run : `npm run test -- Date.test.ts`
|
||||
Expected : PASS (tous, anciens + nouveaux).
|
||||
|
||||
Si un test de saisie échoue parce que `maska` a reformaté la valeur en jsdom autrement qu'attendu, inspecter la valeur réelle via un `console.log((input.element as HTMLInputElement).value)` et ajuster l'assertion (le masque `##/##/####` laisse les chiffres tels quels ; une entrée déjà bien formée n'est pas modifiée).
|
||||
|
||||
- [ ] **Step 4 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/date/Date.test.ts
|
||||
git commit -m "test(date) : couvre la saisie manuelle de MalioDate"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 : Documentation (COMPONENTS.md + CHANGELOG.md)
|
||||
|
||||
**Files:**
|
||||
- Modify: `COMPONENTS.md`
|
||||
- Modify: `CHANGELOG.md`
|
||||
|
||||
- [ ] **Step 1 : Ajouter les props au tableau `MalioDate` de `COMPONENTS.md`**
|
||||
|
||||
Dans la section `## MalioDate`, dans le tableau des props, insérer juste après la ligne `| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |` :
|
||||
|
||||
```markdown
|
||||
| `editable` | `boolean` | `false` | Autorise la saisie clavier `JJ/MM/AAAA` (masque maska, validation au blur) en plus du calendrier |
|
||||
| `invalidMessage` | `string` | `'Date invalide'` | Message affiché quand la saisie clavier est invalide ou hors `min`/`max` |
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Compléter la description et l'exemple `MalioDate`**
|
||||
|
||||
Dans la section `## MalioDate`, juste après la ligne de description `La valeur est une chaîne ISO ...`, ajouter le paragraphe :
|
||||
|
||||
```markdown
|
||||
Avec `editable`, l'utilisateur peut aussi taper la date au clavier. La valeur n'est émise qu'au blur (ou sur Entrée) si elle est valide et dans les bornes ; sinon le texte est conservé et le champ passe en erreur (`invalidMessage`).
|
||||
```
|
||||
|
||||
Dans le bloc d'exemple ```vue de cette section, ajouter une ligne avant la fermeture ``` :
|
||||
|
||||
```vue
|
||||
<MalioDate v-model="date" label="Date de naissance" editable />
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Ajouter l'entrée CHANGELOG**
|
||||
|
||||
Dans `CHANGELOG.md`, sous `### Added`, ajouter à la fin de la liste (après la dernière puce `* [#MUI-41] InputEmail : ...`) :
|
||||
|
||||
```markdown
|
||||
* [#MUI-42] MalioDate : saisie clavier `JJ/MM/AAAA` optionnelle (prop `editable`, masque maska, validation au blur, message `invalidMessage`)
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Commit**
|
||||
|
||||
```bash
|
||||
git add COMPONENTS.md CHANGELOG.md
|
||||
git commit -m "docs(date) : documente la saisie manuelle de MalioDate"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5 : Exemples playground + story
|
||||
|
||||
**Files:**
|
||||
- Modify: `.playground/pages/composant/date/date.vue`
|
||||
- Modify: `app/story/date/datePicker.story.vue`
|
||||
|
||||
- [ ] **Step 1 : Ajouter un bloc éditable dans la page playground**
|
||||
|
||||
Dans `.playground/pages/composant/date/date.vue`, dans la première colonne `Large (480px)`, juste après le `<div class="rounded border p-3 text-sm">...</div>` qui affiche la valeur ISO (≈13-15), ajouter :
|
||||
|
||||
```html
|
||||
<MalioDate
|
||||
v-model="editableValue"
|
||||
label="Date (saisie clavier)"
|
||||
editable
|
||||
hint="Tape JJ/MM/AAAA ou utilise le calendrier"
|
||||
/>
|
||||
<div class="rounded border p-3 text-sm">
|
||||
<p>Valeur éditable (ISO) : <code>{{ editableValue ?? 'null' }}</code></p>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Déclarer la ref dans le `<script setup>` de la page playground**
|
||||
|
||||
Dans le `<script setup>` du même fichier, après `const bounded = ref<string | null>(null)`, ajouter :
|
||||
|
||||
```ts
|
||||
const editableValue = ref<string | null>(null)
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Ajouter un exemple éditable dans la story**
|
||||
|
||||
Dans `app/story/date/datePicker.story.vue`, ajouter une nouvelle carte juste après le bloc `<!-- Avec min/max -->` (le `<div class="rounded-lg border p-4">` qui contient « Avec min/max »), avant le bloc « Non effaçable » :
|
||||
|
||||
```html
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Saisie clavier (editable)</h2>
|
||||
<MalioDate
|
||||
v-model="editableValue"
|
||||
label="Date de naissance"
|
||||
editable
|
||||
hint="Tape JJ/MM/AAAA ou utilise le calendrier"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Déclarer la ref dans le `<script setup>` de la story**
|
||||
|
||||
Dans le `<script setup>` du même fichier, après `const errorValue = ref<string | null>(null)`, ajouter :
|
||||
|
||||
```ts
|
||||
const editableValue = ref<string | null>(null)
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Vérifier que rien ne casse (lint + build des types)**
|
||||
|
||||
Run : `npm run lint`
|
||||
Expected : PASS (aucune erreur sur les fichiers modifiés).
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add .playground/pages/composant/date/date.vue app/story/date/datePicker.story.vue
|
||||
git commit -m "docs(date) : exemples saisie manuelle (playground + story)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6 : Vérification finale
|
||||
|
||||
- [ ] **Step 1 : Lancer toute la suite de tests**
|
||||
|
||||
Run : `npm run test -- Date.test.ts`
|
||||
Expected : PASS — l'ensemble du fichier (anciens + 9 nouveaux tests).
|
||||
|
||||
- [ ] **Step 2 : Lancer le lint global**
|
||||
|
||||
Run : `npm run lint`
|
||||
Expected : PASS.
|
||||
|
||||
- [ ] **Step 3 : Vérification manuelle dans le playground (optionnel mais recommandé)**
|
||||
|
||||
Run : `npm run dev` puis ouvrir la page `composant/date`.
|
||||
Vérifier :
|
||||
- Taper `19/05/2026` puis cliquer ailleurs → la valeur ISO affichée devient `2026-05-19`.
|
||||
- Taper `32/13/2026` puis blur → le texte reste, le champ passe en rouge avec « Date invalide ».
|
||||
- Avec une saisie invalide, ouvrir le calendrier et choisir un jour → l'erreur disparaît, la valeur se met à jour.
|
||||
- Le focus dans le champ ouvre bien le calendrier, et taper reste possible.
|
||||
- Sur le champ `editable=false` existant : aucun changement (lecture seule).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage :**
|
||||
- Prop `editable` opt-in (défaut false) → Task 1 Step 2, Task 2 Step 2.
|
||||
- Masque `##/##/####` + focus ouvre le popover → Task 1 Steps 4/6/7.
|
||||
- Validation au blur, pas à la frappe → Task 1 (onInput ne valide pas) + Task 2 (onCommit).
|
||||
- Saisie invalide : garde le texte + erreur visuelle → Task 2 Step 3 + Task 3 test dédié.
|
||||
- Message par défaut « Date invalide », surchargeable → Task 2 Step 2.
|
||||
- Touche Entrée commit + ferme popover → Task 1 Step 6 (`onEnter`) + Task 3 test.
|
||||
- Hors min/max = invalide → Task 2 (`isDateInRange`) + Task 3 test.
|
||||
- Sélection calendrier efface l'erreur → Task 2 Step 5 (`onSelect`) + Task 3 test.
|
||||
- `disabled`/`readonly` priment → Task 1 (`inputReadonly`, gardes dans handlers).
|
||||
- Non-régression `editable=false` → Task 1 Step 8 + Task 3 test readonly.
|
||||
- Docs COMPONENTS.md + CHANGELOG.md + playground/story → Tasks 4 et 5.
|
||||
|
||||
**Placeholder scan :** aucun TODO/TBD ; tout le code est fourni intégralement.
|
||||
|
||||
**Type consistency :** `editable`/`invalidMessage` (props), `commit` (event CalendarField), `onCommit`/`onClear`/`onSelect`/`internalError`/`mergedError` (MalioDate), `draft`/`maskaOptions`/`inputReadonly`/`onFocus`/`onInput`/`onBlur`/`onEnter` (CalendarField) — noms cohérents entre tâches. `onCommit(text: string)` correspond à l'event `commit(value: string)`. `onSelect(iso: string, close: () => void)` correspond à la signature du slot (`close` exposé par `CalendarField`).
|
||||
Reference in New Issue
Block a user