Files
malio-layer-ui/docs/superpowers/plans/2026-06-09-maliodate-saisie-manuelle.md
T

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 — 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 :

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'état readonly calculé

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 isFilled pour 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 onFieldClick et 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 editable et invalidMessage

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 CalendarField dans 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 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 | :

| `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/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).