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

169 lines
9.0 KiB
Markdown

# 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 :
```vue
<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 :
```vue
{{ 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)
```ts
const sanitizeEmail = (v: string) => {
let out = v.replace(/\s+/g, '') // supprime TOUT espace
if (props.lowercase) out = out.toLowerCase()
return out
}
```
### `onInput` réécrit
```ts
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).