21 KiB
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— modeeditable: prop, masque, bufferdraft, handlers focus/input/blur/enter, eventcommit. - Modify
app/components/malio/date/Date.vue— propseditable/invalidMessage, étatinternalError, handleronCommit, fusionmergedError, 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 :
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 :
editable?: boolean
Dans le bloc withDefaults(..., { ... }), ajouter après clearable: true, :
editable: false,
- Step 3 : Déclarer l'event
commit
Remplacer la ligne (≈152) :
const emit = defineEmits<{(e: 'clear' | 'close'): void}>()
par :
const emit = defineEmits<{
(e: 'clear' | 'close'): void
(e: 'commit', value: string): void
}>()
- Step 4 : Ajouter le buffer
draft, le masque et l'étatreadonlycalculé
Juste après la ligne const root = ref<HTMLElement | null>(null) (≈156), ajouter :
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
isFilledpour tenir compte du buffer
Remplacer (≈164) :
const isFilled = computed(() => props.displayValue.length > 0)
par :
const isFilled = computed(() =>
(props.editable ? draft.value.length : props.displayValue.length) > 0,
)
- Step 6 : Remplacer
onFieldClicket ajouter les handlers éditables
Remplacer le bloc onFieldClick (≈177-185) :
const onFieldClick = () => {
if (props.disabled || props.readonly) return
if (isOpen.value) {
closePopover()
return
}
syncToIso(props.syncTo)
open()
}
par :
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) :
<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 :
<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
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) :
import {formatIsoToDisplay, isValidIso} from './composables/dateFormat'
par :
import {formatIsoToDisplay, isDateInRange, isValidIso, parseDisplayToIso} from './composables/dateFormat'
Et compléter l'import Vue (≈36) pour disposer de ref :
import {computed, ref, watch} from 'vue'
- Step 2 : Ajouter les props
editableetinvalidMessage
Dans defineProps<{...}>(), ajouter après clearable?: boolean :
editable?: boolean
invalidMessage?: string
Dans withDefaults(..., { ... }), ajouter après clearable: true, :
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 :
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
CalendarFielddans le template
Dans <CalendarField ...>, remplacer :error="error" (≈13) par :
:error="mergedError"
Ajouter, juste après :clearable="clearable" (≈15) :
:editable="editable"
Remplacer @clear="emit('update:modelValue', null)" (≈20) par :
@clear="onClear"
@commit="onCommit"
- Step 5 : Brancher la sélection calendrier sur
onSelect
Remplacer (≈29) :
@select="(iso) => { emit('update:modelValue', iso); close() }"
par :
@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
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 :
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 :
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
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
MalioDatedeCOMPONENTS.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 | :
| `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 :
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 :
<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 : ...) :
* [#MUI-42] MalioDate : saisie clavier `JJ/MM/AAAA` optionnelle (prop `editable`, masque maska, validation au blur, message `invalidMessage`)
- Step 4 : Commit
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 :
<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 :
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 » :
<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 :
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
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/2026puis cliquer ailleurs → la valeur ISO affichée devient2026-05-19. - Taper
32/13/2026puis 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=falseexistant : aucun changement (lecture seule).
Self-Review
Spec coverage :
- Prop
editableopt-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/readonlypriment → 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).