feat(ui) : required cohérent + astérisque label + sanitisation email (MUI-41) (#60)

## Résumé (MUI-41)

Harmonise l'état « obligatoire » des composants de formulaire et normalise le champ email.

### `required` + astérisque
- Nouveau composant partagé `MalioRequiredMark` : astérisque rouge (`text-m-danger`, **16px**), `aria-hidden`.
- Prop `required` désormais cohérente sur toute la famille formulaire ; quand vraie, l'astérisque s'affiche **dans le label**.
- Prop ajoutée à `Select`, `SelectCheckbox`, `InputUpload`, `InputRichText` (les autres l'avaient déjà).
- Accessibilité : `required` natif là où l'élément le supporte, sinon `aria-required` (Select/SelectCheckbox sur le `<button>`, RichText sur le wrapper éditeur, Upload sur le champ visible).
- `MalioSiteSelector` **exclu** volontairement (segmented control, pas de label de champ).

### Sanitisation email (`MalioInputEmail`)
- Suppression de **tous les espaces** à la saisie (pas de masque).
- Nouvelle prop opt-in `lowercase` (défaut `false`) : normalise en minuscules à la frappe (cohérent RG-1.21 Starseed).
- Garde défensive curseur : l'API de sélection est interdite sur `type="email"` → repositionnement best-effort sans jamais lever.
- La validation de format reste à la couche `error`.

### Docs & playground
- `COMPONENTS.md` (doc `required` cohérente + note famille + `lowercase`) et `CHANGELOG.md` mis à jour.
- Exemples playground `required` et email `lowercase` ajoutés.

## Test plan
- [x] Suite complète : 42 fichiers / 771 tests verts
- [x] Lint : 0 erreur
- [x] Tests `aria-required` sur Select/SelectCheckbox/RichText
- [ ] Vérif visuelle playground : astérisque 16px dans le label, email qui retire les espaces / minuscule

Spec & plan : `docs/superpowers/specs/` et `docs/superpowers/plans/`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #60
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #60.
This commit is contained in:
2026-06-04 06:42:19 +00:00
committed by Autin
parent aedfaa865d
commit 887ebdebd7
58 changed files with 3192 additions and 167 deletions
@@ -0,0 +1,161 @@
# État visuel `readonly` cohérent — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Donner aux champs `readonly` un état visuel distinct et cohérent : bordure noire même vide, aucun grossissement/bleu au focus, label gris→noir selon rempli, icône gris→noir selon rempli.
**Architecture:** Pas de composant partagé (les styles sont dupliqués par composant, on suit ce pattern). Dans chaque composant on rend conditionnelles 4 zones de classes selon `readonly`. Le champ reste focusable (sélection/copie du texte) mais sans visuel de focus.
**Tech Stack:** Vue 3 `<script setup>`, Tailwind `m-*`, `twMerge`, Vitest + @vue/test-utils.
**Branche:** `feature/MUI-41-props-required-asterisque-dans-le-label-sur-les-co` (on continue dessus).
---
## La recette commune (appliquée quand `readonly === true`)
Priorité inchangée : `error` puis `success` puis `disabled` passent TOUJOURS avant `readonly`. La recette readonly ne s'applique que dans la branche « état normal ».
1. **Bordure** : forcer `border-black` (même vide). Ne PAS inclure `border-m-muted` ni `focus:border-m-primary` quand readonly.
2. **Grow + bleu** : ne PAS inclure la classe `grow-height` (donc pas de grossissement au focus) ni les classes `focus:*` (border, padding `focus:pl-*`/`focus:!pl-*`). Pour `InputTextArea` (pas de `grow-height`) : retirer `focus:border-m-primary` et le surlignage de focus `textarea-scrollbar-primary`.
3. **Label** : utiliser `isFilled ? 'text-black' : 'text-m-muted'` ; ne PAS inclure `peer-focus:text-m-primary` ni les combos `peer-placeholder-shown`/`peer-[&:not(:placeholder-shown):not(:focus)]`. De plus, en readonly, `shouldFloatLabel` (ou équivalent qui pilote le float) doit ignorer `isFocused` → float basé sur `isFilled` seul (un champ readonly vide garde son label gris au repos).
4. **Icône** : `isFilled ? 'text-black' : 'text-m-muted'` ; sauter la branche `isFocused → text-m-primary`. (`error`/`success`/`disabled` toujours prioritaires.)
5. **Interaction** : `readonly` bloque l'ouverture (Upload : `openFilePicker` no-op ; pickers : déjà bloqué). Le champ reste sélectionnable (ne pas retirer la focusabilité).
Implémentation conseillée : un petit computed `isReadonly = computed(() => props.readonly && !props.disabled)` (disabled prime), puis dans chaque `twMerge(...)` remplacer les fragments concernés par des expressions ternaires sur `props.readonly`. Garder le code lisible et homogène avec l'existant du fichier.
### Patron de test (adapter le sélecteur input/textarea et le helper de montage du fichier)
```ts
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
const wrapper = mountX({label: 'Champ', readonly: true}) // pas de modelValue → vide
const field = wrapper.get('input') // ou 'textarea'
expect(field.classes()).toContain('border-black')
expect(field.classes()).not.toContain('border-m-muted')
expect(field.classes()).not.toContain('grow-height') // sauf InputTextArea (pas de grow-height) : asserter l'absence de 'focus:border-m-primary'
expect(field.classes()).not.toContain('focus:border-m-primary')
})
it('readonly : label gris si vide, pas de bleu', () => {
const wrapper = mountX({label: 'Champ', readonly: true})
const label = wrapper.get('label')
expect(label.classes()).not.toContain('peer-focus:text-m-primary')
expect(label.classes()).toContain('text-m-muted')
})
it('readonly rempli : label noir + icône noire', () => {
const wrapper = mountX({label: 'Champ', readonly: true, modelValue: '...valeur remplie...'})
expect(wrapper.get('label').classes()).toContain('text-black')
// si le composant a une icône d'état :
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
```
Pour les composants à icône d'état, ajouter aussi : readonly + vide → icône `text-m-muted`. Adapter `modelValue` au type (montant, date ISO, etc.). Pour les pickers, la « valeur remplie » se passe via la prop d'affichage habituelle (voir tests voisins).
---
## Task 1 : `InputUpload` (ajout de la prop `readonly`)
`InputUpload` n'a PAS de prop `readonly` aujourd'hui (son `<input type="text">` est `:readonly="true"` en dur pour empêcher la saisie). On AJOUTE une vraie prop `readonly`.
**Files:** Modify `app/components/malio/input/InputUpload.vue` ; Test `app/components/malio/input/InputUpload.test.ts`
- [ ] **Step 1 — tests d'abord** : ajouter le patron de test ci-dessus. Champ = `wrapper.get('input[type="text"]')`. Icône = `[data-test="icon"]` (le nuage). « rempli » = `modelValue: 'fichier.pdf'`.
- [ ] **Step 2 — run, FAIL** : `npm run test -- app/components/malio/input/InputUpload.test.ts`
- [ ] **Step 3 — ajouter la prop** : `readonly?: boolean` dans `defineProps` + `readonly: false` dans `withDefaults`.
- [ ] **Step 4 — appliquer la recette** dans `mergedInputClass`, `mergedLabelClass`, `iconStateClass` et `shouldFloatLabel` (float = `isFilled` quand readonly). Forcer `cursor-default` (au lieu de `cursor-pointer`) quand readonly.
- [ ] **Step 5 — bloquer l'ouverture** : dans `openFilePicker`, `if (props.disabled || props.readonly) return`.
- [ ] **Step 6 — run, PASS** : même commande. (Suite flaky connue : relancer le fichier si timeout non lié ; `--no-verify` si un timeout flaky bloque un commit déjà vérifié.)
- [ ] **Step 7 — commit**
```bash
git add app/components/malio/input/InputUpload.vue app/components/malio/input/InputUpload.test.ts
git commit -m "feat(ui) : état readonly visuel sur InputUpload (+ prop readonly)"
```
(corps + ligne vide + `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`)
---
## Task 2 : Inputs floating-label standard (lot de 6)
`InputText`, `InputEmail`, `InputAmount`, `InputAutocomplete`, `InputPassword`, `InputTextArea` ont déjà une prop `readonly`. Appliquer la recette à chacun.
**Files:** Modify les 6 `.vue` (`app/components/malio/input/InputText.vue`, `InputEmail.vue`, `InputAmount.vue`, `InputAutocomplete.vue`, `InputPassword.vue`, `InputTextArea.vue`) ; Test les 6 `*.test.ts` correspondants (`Input.test.ts` pour InputText, puis `InputEmail/InputAmount/InputAutocomplete/InputPassword/InputTextArea.test.ts`).
Spécificités par fichier :
- **InputText / InputEmail / InputAmount** : structure identique (`mergedInputClass` avec `grow-height` + `focus:border-m-primary` + `focus:pl-[11px]` ; `mergedLabelClass` avec `peer-focus:text-m-primary` ; `iconStateClass` avec branche `isFocused`). Appliquer la recette 1-4.
- **InputAutocomplete** : idem ; il a deux usages de `iconStateClass` (icône gauche + chevron) — appliquer la recette à `iconStateClass`. `isFilled` y inclut `hasSelection`.
- **InputPassword** : recette 1-4. L'icône est le **toggle œil** (cliquable) : garder le `@click` de bascule ; seule la couleur suit la recette (pas de bleu). NE PAS rendre l'œil non-cliquable en readonly.
- **InputTextArea** : classes **inline** dans le template (pas de `grow-height`). Recette : `isFilled ? border-black : border-m-muted``readonly ? border-black : (isFilled ? border-black : border-m-muted)` ; retirer `focus:border-m-primary` et le `isFocused ? 'textarea-scrollbar-primary'` quand readonly ; label idem. Pas d'icône (recette 4 N/A).
- [ ] **Step 1 — tests d'abord** : ajouter le patron à chacun des 6 fichiers test (champ = `input`, sauf TextArea = `textarea` ; pour TextArea ne pas asserter `grow-height`). Pour les composants à icône, asserter aussi l'icône (`[data-test="icon"]`). Adapter `modelValue` rempli au type (Amount : un montant valide).
- [ ] **Step 2 — run, FAIL** : `npm run test -- app/components/malio/input/Input.test.ts app/components/malio/input/InputEmail.test.ts app/components/malio/input/InputAmount.test.ts app/components/malio/input/InputAutocomplete.test.ts app/components/malio/input/InputPassword.test.ts app/components/malio/input/InputTextArea.test.ts`
- [ ] **Step 3 — appliquer la recette** aux 6 `.vue`.
- [ ] **Step 4 — run, PASS** : même commande (relancer un fichier si flaky).
- [ ] **Step 5 — commit**
```bash
git add app/components/malio/input/InputText.vue app/components/malio/input/InputEmail.vue app/components/malio/input/InputAmount.vue app/components/malio/input/InputAutocomplete.vue app/components/malio/input/InputPassword.vue app/components/malio/input/InputTextArea.vue app/components/malio/input/Input.test.ts app/components/malio/input/InputEmail.test.ts app/components/malio/input/InputAmount.test.ts app/components/malio/input/InputAutocomplete.test.ts app/components/malio/input/InputPassword.test.ts app/components/malio/input/InputTextArea.test.ts
git commit -m "feat(ui) : état readonly visuel sur les inputs floating-label"
```
---
## Task 3 : `InputPhone` (découpler readonly de disabled)
Aujourd'hui `InputPhone` traite `disabled || readonly` ensemble (bouton « add » + `opacity-40`, look désactivé). On découple : readonly applique la recette readonly (bordure noire, pas de look disabled), tout en restant non-éditable. L'action « add » reste bloquée en readonly mais le **champ** ne doit plus avoir l'apparence désactivée.
**Files:** Modify `app/components/malio/input/InputPhone.vue` ; Test `app/components/malio/input/InputPhone.test.ts`
- [ ] **Step 1 — tests d'abord** : patron readonly (champ = `input`, icône = `[data-test="icon"]`). Ajouter aussi une assertion que le champ readonly n'a PAS `opacity-40` (plus de look disabled). `modelValue` rempli = un numéro.
- [ ] **Step 2 — run, FAIL** : `npm run test -- app/components/malio/input/InputPhone.test.ts`
- [ ] **Step 3 — appliquer la recette** à `mergedInputClass`/`mergedLabelClass`/`iconStateClass` (recette 1-4). Pour le bouton « add » (`mergedAddButtonClass`) : garder l'action bloquée en readonly (`onAdd` retourne déjà), mais retirer l'apparence `opacity-40 cursor-not-allowed` spécifique au readonly — la garder uniquement pour `disabled`. (En readonly, le bouton add suit la couleur d'icône readonly.)
- [ ] **Step 4 — run, PASS** (relancer si flaky).
- [ ] **Step 5 — commit**
```bash
git add app/components/malio/input/InputPhone.vue app/components/malio/input/InputPhone.test.ts
git commit -m "feat(ui) : InputPhone readonly suit les règles readonly (plus de look disabled)"
```
---
## Task 4 : Pickers `CalendarField` (date family) + `TimePicker`
`CalendarField` (rend Date/DateTime/DateRange/DateWeek) et `TimePicker` ont déjà une prop `readonly` qui bloque l'ouverture du popover. Appliquer la recette visuelle. Leur input interne est déjà `readonly` natif ; le float du label suit `isFilled || isOpen` — en readonly, `isOpen` reste faux (ouverture bloquée), donc float = `isFilled`. Forcer bordure noire, label gris→noir, icône gris→noir sans branche focus/open.
**Files:** Modify `app/components/malio/date/internal/CalendarField.vue`, `app/components/malio/time/TimePicker.vue` ; Test `app/components/malio/date/Date.test.ts` (couvre CalendarField) et `app/components/malio/time/TimePicker.test.ts`
- [ ] **Step 1 — tests d'abord** : patron readonly. Pour `Date.test.ts`, monter `mountDate({label, readonly: true})` et une variante remplie (passer une valeur de date ISO comme les tests voisins). Champ = l'input du composant (voir sélecteur utilisé par les tests voisins). Pour `TimePicker.test.ts`, utiliser le helper du fichier.
- [ ] **Step 2 — run, FAIL** : `npm run test -- app/components/malio/date/Date.test.ts app/components/malio/time/TimePicker.test.ts`
- [ ] **Step 3 — appliquer la recette** à `CalendarField.vue` et `TimePicker.vue` (`mergedInputClass`/`mergedLabelClass`/`iconStateClass` ; float = `isFilled` en readonly). Vérifier que la croix « clear » reste masquée en readonly (déjà le cas — ne pas régresser).
- [ ] **Step 4 — run, PASS** (relancer si flaky).
- [ ] **Step 5 — commit**
```bash
git add app/components/malio/date/internal/CalendarField.vue app/components/malio/time/TimePicker.vue app/components/malio/date/Date.test.ts app/components/malio/time/TimePicker.test.ts
git commit -m "feat(ui) : état readonly visuel sur pickers date/heure"
```
---
## Task 5 : Playground + vérification finale
**Files:** Modify les pages playground concernées sous `.playground/pages/composant/...`
- [ ] **Step 1 — exemples readonly** : ajouter sur chaque page concernée (inputText, inputEmail, inputAmount, inputAutocomplete, inputPassword, inputTextArea, inputPhone, **inputUpload** [manquant], date, timePicker) un exemple readonly : une instance vide (`:readonly="true"`) ET une instance remplie readonly, pour visualiser bordure noire vide + label/icône noir rempli. Suivre le pattern de chaque page ; si une page rend l'ajout coûteux, le signaler et passer (mais inputUpload est demandé explicitement, le faire).
- [ ] **Step 2 — lint** : `npm run lint` → 0 erreur (baseline 24 warnings préexistants).
- [ ] **Step 3 — suite complète** : `npm run test` → tout vert (relancer un fichier en cas de timeout flaky).
- [ ] **Step 4 — commit**
```bash
git add .playground
git commit -m "docs(playground) : exemples readonly"
```
---
## Récapitulatif commits attendus
1. `feat(ui) : état readonly visuel sur InputUpload (+ prop readonly)`
2. `feat(ui) : état readonly visuel sur les inputs floating-label`
3. `feat(ui) : InputPhone readonly suit les règles readonly (plus de look disabled)`
4. `feat(ui) : état readonly visuel sur pickers date/heure`
5. `docs(playground) : exemples readonly`
Note convention : le hook commit-msg malio impose un espace avant `:`.
@@ -0,0 +1,460 @@
# É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` :
```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` :
```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**
```bash
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) :
```ts
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 :
```ts
required?: boolean
```
Dans chaque `withDefaults(…, { … })`, ajouter :
```ts
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 :
```vue
:aria-required="required || undefined"
```
`SelectCheckbox.vue` — idem, sur son `<button>` déclencheur :
```vue
:aria-required="required || undefined"
```
`InputUpload.vue` — sur l'`<input type="file">`, ajouter l'attribut natif :
```vue
:required="required"
```
`InputRichText.vue` — sur le wrapper éditeur identifié par `:id="editorId"` (le conteneur de `<EditorContent>` en mode éditable), ajouter :
```vue
: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`) :
```ts
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 :
```vue
{{ 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**
```bash
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 :
```ts
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 :
```vue
{{ 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**
```bash
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` :
```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 :
```ts
lowercase?: boolean
```
Dans `withDefaults(…, { … })`, ajouter :
```ts
lowercase: false,
```
- [ ] **Step 4 : Ajouter la fonction de sanitisation et réécrire `onInput`**
Ajouter la fonction pure (au-dessus de `onInput`) :
```ts
const sanitizeEmail = (v: string) => {
let out = v.replace(/\s+/g, '')
if (props.lowercase) out = out.toLowerCase()
return out
}
```
Remplacer le `onInput` existant par :
```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. 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**
```bash
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**
```bash
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)**
```bash
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)