Files
malio-layer-ui/docs/superpowers/plans/2026-06-03-required-asterisk-email-sanitization.md
T

20 KiB
Raw Blame History

État « obligatoire » cohérent + normalisation email — 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: Exposer une prop required cohérente avec astérisque rouge dans le label sur toute la famille formulaire, et ajouter une sanitisation à la saisie (suppression des espaces + option lowercase) à MalioInputEmail.

Architecture : Un composant présentational partagé MalioRequiredMark (astérisque aria-hidden, token text-m-danger) est importé explicitement et rendu dans le <label> de chaque composant quand required est vrai. Les 4 composants sans la prop la reçoivent (+ câblage aria-required là où il n'y a pas de required natif). MalioInputEmail.onInput sanitise la valeur avant émission.

Tech Stack : Nuxt 4 layer, Vue 3 <script setup lang="ts">, Tailwind (palette m-*), tailwind-merge, Vitest + @vue/test-utils (jsdom).

Spec : docs/superpowers/specs/2026-06-03-required-asterisk-email-sanitization-design.md

Conventions de test (rappel) : chaque fichier *.test.ts définit son propre helper de montage (nom variable : mountInput, mountDate, mountCheckbox, mountTime, mountComponent…) ou monte en inline. Le tableau de chaque tâche indique le helper exact à réutiliser.

⚠️ Suite flaky : des timeouts intermittents existent sur diverses suites. Si un test échoue par timeout sans rapport avec le changement, relancer le fichier ciblé ; ne pas conclure à un échec sans relance. Le hook pre-commit lance les tests — si un timeout flaky bloque un commit déjà vérifié manuellement, utiliser git commit --no-verify.

Branche : feature/MUI-41-props-required-asterisque-dans-le-label-sur-les-co (rester dessus, ne pas créer de branche).


Task 1 : Composant partagé MalioRequiredMark

Files:

  • Create: app/components/malio/shared/RequiredMark.vue

  • Test: app/components/malio/shared/RequiredMark.test.ts

  • Step 1 : Écrire le test qui échoue

Create app/components/malio/shared/RequiredMark.test.ts :

import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import RequiredMark from './RequiredMark.vue'

describe('MalioRequiredMark', () => {
  it('rend un astérisque', () => {
    const wrapper = mount(RequiredMark)
    expect(wrapper.text()).toBe('*')
  })

  it('est masqué pour les technologies dassistance', () => {
    const wrapper = mount(RequiredMark)
    expect(wrapper.get('[data-test="required-mark"]').attributes('aria-hidden')).toBe('true')
  })

  it('utilise le token de couleur danger', () => {
    const wrapper = mount(RequiredMark)
    expect(wrapper.get('[data-test="required-mark"]').classes()).toContain('text-m-danger')
  })
})
  • Step 2 : Lancer le test, vérifier l'échec

Run: npm run test -- app/components/malio/shared/RequiredMark.test.ts Expected: FAIL — Failed to resolve import './RequiredMark.vue' (le composant n'existe pas encore).

  • Step 3 : Créer le composant

Create app/components/malio/shared/RequiredMark.vue :

<template>
  <span
    data-test="required-mark"
    aria-hidden="true"
    class="ml-0.5 select-none text-m-danger"
  >*</span>
</template>

<script setup lang="ts">
defineOptions({name: 'MalioRequiredMark', inheritAttrs: false})
</script>
  • Step 4 : Lancer le test, vérifier le succès

Run: npm run test -- app/components/malio/shared/RequiredMark.test.ts Expected: PASS (3 tests).

  • Step 5 : Commit
git add app/components/malio/shared/RequiredMark.vue app/components/malio/shared/RequiredMark.test.ts
git commit -m "feat(ui): composant partagé MalioRequiredMark (astérisque champ obligatoire)"

Task 2 : Prop required + a11y + astérisque sur les 4 composants sans la prop

Composants : Select, SelectCheckbox, InputUpload, InputRichText. Chacun reçoit la prop required, le câblage a11y adapté, l'import + le rendu de l'astérisque, et un test.

Files:

  • Modify: app/components/malio/select/Select.vue, app/components/malio/select/SelectCheckbox.vue, app/components/malio/input/InputUpload.vue, app/components/malio/input/InputRichText.vue

  • Test: app/components/malio/select/Select.test.ts, app/components/malio/select/SelectCheckbox.test.ts, app/components/malio/input/InputUpload.test.ts, app/components/malio/input/InputRichText.test.ts

  • Step 1 : Écrire les tests qui échouent (un par composant)

Patron d'assertion (à adapter au helper de chaque fichier) :

it('affiche lastérisque quand required est vrai', () => {
  const wrapper = /* monter avec { label: 'Champ', required: true, ...props requises } */
  expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
})

it('naffiche pas lastérisque par défaut', () => {
  const wrapper = /* monter avec { label: 'Champ', ...props requises } */
  expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})

Montage par fichier :

Fichier test Montage
select/Select.test.ts inline : mount(SelectForTest, {props: {label: 'Champ', required: true, options: [{label: 'A', value: 'a'}]}}) (et sans required pour le 2ᵉ test)
select/SelectCheckbox.test.ts inline : mount(SelectCheckboxForTest, {props: {label: 'Champ', required: true, options: [{label: 'A', value: 'a'}]}})
input/InputUpload.test.ts helper existant mountComponent({label: 'Champ', required: true})
input/InputRichText.test.ts helper existant mountComponent({label: 'Champ', required: true})

Note : pour Select/SelectCheckbox, reprendre la forme exacte des options et les global.stubs déjà utilisés dans les autres it() du fichier (copier un montage voisin).

  • Step 2 : Lancer les tests, vérifier l'échec

Run: npm run test -- app/components/malio/select/Select.test.ts app/components/malio/select/SelectCheckbox.test.ts app/components/malio/input/InputUpload.test.ts app/components/malio/input/InputRichText.test.ts Expected: FAIL sur les nouveaux tests « affiche lastérisque » (la prop/le rendu n'existent pas encore).

  • Step 3 : Ajouter la prop required (type + défaut) dans les 4 composants

Dans chaque defineProps<{…}>(), ajouter la ligne :

    required?: boolean

Dans chaque withDefaults(…, { … }), ajouter :

    required: false,
  • Step 4 : Câbler l'accessibilité (un élément interactif par composant)

Select.vue — sur le <button> déclencheur (là où sont déjà :aria-expanded, :aria-controls), ajouter :

        :aria-required="required || undefined"

SelectCheckbox.vue — idem, sur son <button> déclencheur :

        :aria-required="required || undefined"

InputUpload.vue — sur l'<input type="file">, ajouter l'attribut natif :

        :required="required"

InputRichText.vue — sur le wrapper éditeur identifié par :id="editorId" (le conteneur de <EditorContent> en mode éditable), ajouter :

      :aria-required="required || undefined"
  • Step 5 : Importer et rendre l'astérisque dans les 4 composants

Dans le <script setup> de chacun, ajouter l'import (chemin relatif depuis family/Component.vue) :

import MalioRequiredMark from '../shared/RequiredMark.vue'

Dans le <template>, remplacer le rendu du libellé {{ label }} (celui à l'intérieur du <label> du champ — pas un {{ opt.label }}) par :

        {{ label }}<MalioRequiredMark v-if="required" />

Respecter l'indentation existante de chaque fichier. Pour Select/SelectCheckbox, viser le {{ label }} du <label> flottant, pas le {{ opt.label }} des options.

  • Step 6 : Lancer les tests, vérifier le succès

Run: npm run test -- app/components/malio/select/Select.test.ts app/components/malio/select/SelectCheckbox.test.ts app/components/malio/input/InputUpload.test.ts app/components/malio/input/InputRichText.test.ts Expected: PASS (anciens + nouveaux tests). En cas de timeout flaky non lié, relancer le fichier concerné.

  • Step 7 : Commit
git add app/components/malio/select/Select.vue app/components/malio/select/SelectCheckbox.vue app/components/malio/input/InputUpload.vue app/components/malio/input/InputRichText.vue app/components/malio/select/Select.test.ts app/components/malio/select/SelectCheckbox.test.ts app/components/malio/input/InputUpload.test.ts app/components/malio/input/InputRichText.test.ts
git commit -m "feat(ui): prop required + aria-required + astérisque sur Select/SelectCheckbox/Upload/RichText"

Task 3 : Astérisque sur les composants ayant déjà required

Ces composants ont déjà la prop required (câblée nativement). On ajoute uniquement l'import + le rendu de l'astérisque + un test.

Files (16 composants → 13 via CalendarField mutualisé) :

Composant .vue Import à ajouter Fichier test Helper de montage
input/InputText.vue '../shared/RequiredMark.vue' input/Input.test.ts mountInput({label:'Champ', required:true})
input/InputEmail.vue '../shared/RequiredMark.vue' input/InputEmail.test.ts mountComponent({label:'Champ', required:true})
input/InputPhone.vue '../shared/RequiredMark.vue' input/InputPhone.test.ts mountComponent({label:'Champ', required:true})
input/InputPassword.vue '../shared/RequiredMark.vue' input/InputPassword.test.ts mountComponent({label:'Champ', required:true})
input/InputTextArea.vue '../shared/RequiredMark.vue' input/InputTextArea.test.ts helper du fichier (mount<…> ; copier un montage voisin)
input/InputAmount.vue '../shared/RequiredMark.vue' input/InputAmount.test.ts helper du fichier
input/InputNumber.vue '../shared/RequiredMark.vue' input/InputNumber.test.ts helper du fichier
input/InputAutocomplete.vue '../shared/RequiredMark.vue' input/InputAutocomplete.test.ts mountComponent({label:'Champ', required:true, …props requises})
checkbox/Checkbox.vue '../shared/RequiredMark.vue' checkbox/Checkbox.test.ts mountCheckbox({label:'Champ', required:true})
radio/RadioButton.vue '../shared/RequiredMark.vue' radio/RadioButton.test.ts helper du fichier
time/Time.vue '../shared/RequiredMark.vue' time/Time.test.ts mountTime({label:'Champ', required:true})
time/TimePicker.vue '../shared/RequiredMark.vue' time/TimePicker.test.ts helper du fichier
date/internal/CalendarField.vue '../../shared/RequiredMark.vue' date/Date.test.ts mountDate({label:'Champ', required:true})

CalendarField rend le label de tout le date family (Date, DateTime, DateRange, DateWeek). Une seule modif + un seul test (via Date.test.ts) couvrent les quatre.

  • Step 1 : Écrire les tests qui échouent (un couple par fichier test du tableau)

Pour chaque fichier test listé, ajouter :

it('affiche lastérisque quand required est vrai', () => {
  const wrapper = /* helper du tableau, avec required: true */
  expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
})

it('naffiche pas lastérisque par défaut', () => {
  const wrapper = /* helper du tableau, sans required */
  expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
  • Step 2 : Lancer les tests, vérifier l'échec

Run: npm run test -- app/components/malio/input app/components/malio/checkbox app/components/malio/radio app/components/malio/time app/components/malio/date/Date.test.ts Expected: FAIL sur les nouveaux tests « affiche lastérisque ».

  • Step 3 : Ajouter l'import + le rendu de l'astérisque dans les 13 .vue

Dans chaque <script setup>, ajouter l'import indiqué dans la colonne « Import à ajouter ».

Dans chaque <template>, transformer le libellé du champ :

        {{ label }}<MalioRequiredMark v-if="required" />

(Le {{ label }} est à l'intérieur du <label v-if="label"> du champ. Respecter l'indentation propre à chaque fichier.)

  • Step 4 : Lancer les tests, vérifier le succès

Run: npm run test -- app/components/malio/input app/components/malio/checkbox app/components/malio/radio app/components/malio/time app/components/malio/date/Date.test.ts Expected: PASS. (Vérifier notamment que input/InputEmail.test.ts « renders the label text » → 'Adresse email' passe toujours : pas de required dans ce test, donc pas d'astérisque.) Relancer en cas de timeout flaky.

  • Step 5 : Commit
git add app/components/malio/input app/components/malio/checkbox app/components/malio/radio app/components/malio/time app/components/malio/date/internal/CalendarField.vue app/components/malio/date/Date.test.ts
git commit -m "feat(ui): astérisque required dans le label de la famille formulaire"

Task 4 : Sanitisation de MalioInputEmail

Files:

  • Modify: app/components/malio/input/InputEmail.vue

  • Test: app/components/malio/input/InputEmail.test.ts

  • Step 1 : Écrire les tests qui échouent

Ajouter à input/InputEmail.test.ts :

it('supprime tous les espaces saisis', async () => {
  const wrapper = mountComponent()
  await wrapper.get('input').setValue('  a b @ c.com ')
  const emits = wrapper.emitted('update:modelValue')!
  expect(emits[emits.length - 1]).toEqual(['ab@c.com'])
  expect(wrapper.get('input').element.value).toBe('ab@c.com')
})

it('conserve la casse par défaut', async () => {
  const wrapper = mountComponent()
  await wrapper.get('input').setValue('User@Example.COM')
  const emits = wrapper.emitted('update:modelValue')!
  expect(emits[emits.length - 1]).toEqual(['User@Example.COM'])
})

it('met en minuscules quand lowercase est vrai', async () => {
  const wrapper = mountComponent({lowercase: true})
  await wrapper.get('input').setValue('User@Example.COM')
  const emits = wrapper.emitted('update:modelValue')!
  expect(emits[emits.length - 1]).toEqual(['user@example.com'])
})

Ajouter lowercase?: boolean au type InputEmailProps en tête du fichier de test (sinon TS refuse la prop dans le 3ᵉ test).

  • Step 2 : Lancer les tests, vérifier l'échec

Run: npm run test -- app/components/malio/input/InputEmail.test.ts Expected: FAIL — les espaces ne sont pas supprimés / lowercase inconnu.

  • Step 3 : Ajouter la prop lowercase

Dans defineProps<{…}>() de InputEmail.vue, ajouter :

    lowercase?: boolean

Dans withDefaults(…, { … }), ajouter :

    lowercase: false,
  • Step 4 : Ajouter la fonction de sanitisation et réécrire onInput

Ajouter la fonction pure (au-dessus de onInput) :

const sanitizeEmail = (v: string) => {
  let out = v.replace(/\s+/g, '')
  if (props.lowercase) out = out.toLowerCase()
  return out
}

Remplacer le onInput existant par :

const onInput = (event: Event) => {
  const target = event.target as HTMLInputElement
  const raw = target.value
  const sanitized = sanitizeEmail(raw)

  if (sanitized !== raw) {
    // `<input type="email">` ne supporte pas l'API de sélection :
    // selectionStart vaut null, setSelectionRange lève. On garde defensivement.
    const caret = target.selectionStart
    target.value = sanitized
    if (caret !== null) {
      const newCaret = sanitizeEmail(raw.slice(0, caret)).length
      try {
        target.setSelectionRange(newCaret, newCaret)
      } catch {
        /* type d'input sans support de sélection — ignore */
      }
    }
  }

  if (!isControlled.value) {
    localValue.value = sanitized
  }
  emit('update:modelValue', sanitized)
}
  • Step 5 : Lancer les tests, vérifier le succès

Run: npm run test -- app/components/malio/input/InputEmail.test.ts Expected: PASS (anciens tests inclus, dont « emits update:modelValue on input change » avec 'new@example.com' qui n'a pas d'espace → inchangé).

  • Step 6 : Commit
git add app/components/malio/input/InputEmail.vue app/components/malio/input/InputEmail.test.ts
git commit -m "feat(inputs): sanitisation email (suppression des espaces + option lowercase)"

Task 5 : Documentation (COMPONENTS.md + CHANGELOG.md)

Files:

  • Modify: COMPONENTS.md, CHANGELOG.md

  • Step 1 : COMPONENTS.md — lignes required manquantes

Pour les sections MalioSelect, MalioSelectCheckbox, MalioInputUpload, MalioInputRichText, ajouter dans le tableau des props la ligne (au même format que les autres composants) :

| `required` | `boolean` | `false` | Champ requis (affiche un astérisque rouge dans le label) |
  • Step 2 : COMPONENTS.md — note astérisque + prop lowercase

  • Dans l'introduction de la famille formulaire (ou la section des props communes), ajouter une phrase : « Lorsque required est vrai, un astérisque rouge est ajouté dans le label (visuel ; la sémantique est portée par l'attribut required/aria-required). »

  • Dans la section MalioInputEmail, ajouter la ligne de prop :

| `lowercase` | `boolean` | `false` | Normalise la saisie en minuscules à la frappe |

et préciser que les espaces sont supprimés automatiquement à la saisie (pas de masque ; la validation de format reste à la couche error).

  • Step 3 : CHANGELOG.md — entrées

Sous le ### Added de la version en cours (format * [#…] …), ajouter :

* [#MUI-41] Prop `required` cohérente + astérisque rouge dans le label sur la famille formulaire
* [#MUI-41] InputEmail : sanitisation à la saisie (suppression des espaces, option `lowercase`)
  • Step 4 : Commit
git add COMPONENTS.md CHANGELOG.md
git commit -m "docs: required/astérisque + lowercase email (COMPONENTS + CHANGELOG)"

Task 6 : Exemples playground + vérification finale

Files:

  • Modify: page(s) playground des composants concernés (selon .playground/ ; cf. mémoire « Architecture playground »)

  • Step 1 : Ajouter des exemples légers

Sur la page playground d'un composant représentatif (ex. InputText/Select), ajouter une instance :required="true". Sur la page InputEmail, ajouter une instance :lowercase="true". Si le coût d'intégration dépasse quelques minutes (routage/nav à câbler), le noter et passer — c'est hors scope strict du ticket.

  • Step 2 : Lint

Run: npm run lint Expected: 0 erreur. Corriger le cas échéant.

  • Step 3 : Suite de tests complète des fichiers touchés

Run: npm run test -- app/components/malio/shared app/components/malio/input app/components/malio/select app/components/malio/checkbox app/components/malio/radio app/components/malio/time app/components/malio/date Expected: PASS. En cas de timeout flaky, relancer le(s) fichier(s) concerné(s) individuellement.

  • Step 4 : Commit (si exemples playground ajoutés)
git add .playground
git commit -m "docs(playground): exemples required + email lowercase"

Récapitulatif des commits attendus

  1. feat(ui): composant partagé MalioRequiredMark (astérisque champ obligatoire)
  2. feat(ui): prop required + aria-required + astérisque sur Select/SelectCheckbox/Upload/RichText
  3. feat(ui): astérisque required dans le label de la famille formulaire
  4. feat(inputs): sanitisation email (suppression des espaces + option lowercase)
  5. docs: required/astérisque + lowercase email (COMPONENTS + CHANGELOG)
  6. docs(playground): exemples required + email lowercase (optionnel)