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

9.0 KiB

Design — État « obligatoire » cohérent + normalisation email

  • Date : 2026-06-03
  • Ticket Malio UI : MUI-41 (branche feature/MUI-41-props-required-asterisque-dans-le-label-sur-les-co)
  • Ticket Starseed lié : ERP-101 (MAJ Malio UI + branchement required + stratégie de validation), découvert pendant ERP-63 (écran « Ajouter un client »)

Contexte & problème

Pendant ERP-63, deux manques ont bloqué la mise en place de champs obligatoires :

  1. Certains composants de formulaire n'exposent pas de prop required (MalioSelect, MalioSelectCheckbox), et aucun composant n'affiche d'indicateur visuel de champ obligatoire. Résultat : le bouton « Valider » se bloque sans feedback à l'utilisateur — anti-pattern UX.
  2. Tentation erronée de « masquer » l'email à la maska. Un email n'a pas de structure fixe : pas de masque. Le bon comportement est une sanitisation légère à la saisie + validation déléguée à la couche error.

État réel constaté (inventaire) : la majorité des composants ont déjà la prop required (câblée sur l'attribut HTML natif uniquement, sans astérisque). Seuls 5 ne l'ont pas : Select, SelectCheckbox, InputUpload, InputRichText, SiteSelector. Aucun composant n'affiche d'astérisque. Il n'existe pas de composant de label partagé : chaque composant rend {{ label }} dans son propre <label> au style spécifique (floating labels).

Objectifs

  • Prop required: boolean cohérente sur toute la famille formulaire.
  • Quand required est vrai → astérisque rouge dans le label.
  • MalioInputEmail : sanitisation à la saisie (suppression de tous les espaces, option lowercase), sans masque ni validation de format.
  • Mettre à jour COMPONENTS.md et CHANGELOG.md.

Hors scope

  • Validation de format email (reste à la charge de la couche validation via la prop error, alimentée serveur ou check client).
  • Toute logique de masque sur l'email.
  • Refonte des suites de tests existantes.

Décisions de cadrage (validées avec l'utilisateur)

Décision Choix retenu
Périmètre required + astérisque Toute la famille formulaire, y compris InputUpload, InputRichText, SiteSelector
Prop lowercase (email) Opt-in, défaut false
Espaces email Supprimer tous les espaces (début, milieu, fin) ; préservation du curseur best-effort (voir caveat ci-dessous)
Accessibilité astérisque aria-hidden="true" — la sémantique est portée par l'attribut HTML natif required

Section 1 — Indicateur « obligatoire »

Composant partagé MalioRequiredMark

Nouveau composant app/components/malio/shared/RequiredMark.vue (auto-importé <MalioRequiredMark>). Source unique de vérité pour couleur/espacement.

Rendu :

<span aria-hidden="true" class="ml-0.5 select-none text-m-danger">*</span>
  • aria-hidden="true" : évite la double annonce, la sémantique est déjà sur l'attribut natif required.
  • Couleur via token existant text-m-danger (--m-danger, rouge #F2696B).
  • defineOptions({ name: 'MalioRequiredMark', inheritAttrs: false }).

Intégration

Dans chaque composant de la famille, remplacer {{ label }} par :

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

L'astérisque vit à l'intérieur du <label> → il flotte avec le floating-label et reste dans la pastille blanche.

Props à ajouter

required?: boolean (défaut false) sur les 4 composants qui ne l'ont pas et qui possèdent un label de champ : Select, SelectCheckbox, InputUpload, InputRichText.

Câblage accessibilité (a11y)

L'astérisque est aria-hidden : la sémantique « obligatoire » doit donc être portée par le DOM.

  • Élément natif required déjà câblé (asterisque suffit) : InputText, InputEmail, InputPhone, InputPassword, InputTextArea, InputAmount, InputNumber, InputAutocomplete, Checkbox, RadioButton, Time, TimePicker, et CalendarField (date family).
  • Pas de required natif → ajouter :aria-required="required || undefined" sur l'élément interactif :
    • Select / SelectCheckbox : le <button> déclencheur (combobox).
    • InputRichText : le wrapper éditeur (#editorId, contenteditable via TipTap).
    • InputUpload : possède un <input type="file"> natif → on câble :required="required" dessus (natif).

Composants concernés par le rendu de l'astérisque

InputText, InputEmail, InputPhone, InputPassword, InputTextArea, InputAmount, InputNumber, InputAutocomplete, InputUpload, InputRichText, Select, SelectCheckbox, Checkbox, RadioButton, Time, TimePicker, et CalendarField (rendu mutualisé pour Date, DateTime, DateRange, DateWeek).

Exclusion : SiteSelector

MalioSiteSelector est un radiogroup de tuiles (segmented control) : il n'a pas de label de champ (son labelClass style le nom de chaque tuile). Y placer un astérisque n'a pas de sens. Il est exclu du périmètre required/astérisque. À rouvrir si un besoin de « groupe obligatoire » émerge (ce serait alors un libellé de groupe distinct, hors de ce ticket).

Alternative écartée

Inliner un <span> dans chaque composant : duplication, couleur/espacement à changer à ~20 endroits. Le composant partagé est préféré.

Section 2 — Sanitisation MalioInputEmail

Nouvelle prop

lowercase?: boolean (défaut false).

Fonction de sanitisation (pure, testable)

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

onInput réécrit

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 une exception.
    // On garde donc la repositionnement défensif (no-op sur type=email).
    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)
}

Points clés :

  • Resynchro DOM : target.value = sanitized même en mode contrôlé, pour que l'affichage colle toujours à la valeur émise.
  • Caveat curseur : la spec HTML interdit l'API de sélection sur type="email" (selectionStart = null, setSelectionRange lève). La repositionnement est donc best-effort et inactif sur l'email : sur le cas rare d'une suppression d'espace en milieu de chaîne, le curseur peut aller en fin. Les cas courants (espace en fin, collage) gardent naturellement le curseur en fin. Le code est gardé (caret !== null + try/catch) pour ne jamais lever.
  • Collage couvert (paste déclenche input).
  • Inchangé : type="email", inputmode="email", icône, et aucune validation de format.

Section 3 — Tests, docs & livraison

Tests (colocalisés *.test.ts)

  • RequiredMark.test.ts — rend *, aria-hidden="true", classe text-m-danger.
  • 1 test ciblé par composant équipé : required: true → astérisque présent dans le label ; défaut → absent. S'appuie sur le helper mountComponent existant de chaque fichier.
  • InputEmail.test.ts — espaces (début/milieu/fin) supprimés ; lowercase=false préserve la casse ; lowercase=true minuscule ; valeur émise sanitisée ; valeur DOM resynchronisée. Le curseur n'est pas testé (peu fiable en jsdom) → on teste la valeur.

⚠️ Suite de tests flaky connue (timeouts intermittents). Lancer les tests des fichiers touchés ; en cas de timeout non lié aux changements, relancer / documenter plutôt que conclure à un échec.

Documentation (manuelle, requise par convention)

  • COMPONENTS.md : ajouter la ligne required aux 5 composants manquants ; ajouter lowercase à MalioInputEmail ; mentionner en intro famille formulaire que required affiche un astérisque rouge.
  • CHANGELOG.md : entrée(s) MUI-41 sous ### Added, format existant (* [#MUI-41] ...).

Playground / Histoire

Ajouter un exemple required + un exemple email lowercase sur les pages playground concernées si coût faible ; sinon signaler (hors scope strict).

Découpage de livraison (1 PR, commits Conventional)

  1. feat(ui): MalioRequiredMark + prop required sur Select/SelectCheckbox/Upload/RichText/SiteSelector
  2. feat(ui): astérisque required dans le label de la famille formulaire
  3. feat(inputs): sanitisation email (suppression espaces + option lowercase)
  4. docs: COMPONENTS.md + CHANGELOG

Branche : feature/MUI-41-props-required-asterisque-dans-le-label-sur-les-co (inchangée).