docs: plan d'implémentation MUI-41 + précisions spec (caret email, exclusion SiteSelector)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 11:23:31 +02:00
parent 05949b727e
commit df289aa829
2 changed files with 490 additions and 7 deletions
@@ -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)
@@ -32,7 +32,7 @@ Pendant ERP-63, deux manques ont bloqué la mise en place de champs obligatoires
|---|---| |---|---|
| Périmètre `required` + astérisque | **Toute la famille formulaire**, y compris `InputUpload`, `InputRichText`, `SiteSelector` | | Périmètre `required` + astérisque | **Toute la famille formulaire**, y compris `InputUpload`, `InputRichText`, `SiteSelector` |
| Prop `lowercase` (email) | **Opt-in, défaut `false`** | | Prop `lowercase` (email) | **Opt-in, défaut `false`** |
| Espaces email | **Supprimer tous les espaces** (début, milieu, fin), avec préservation du curseur | | 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` | | Accessibilité astérisque | `aria-hidden="true"` — la sémantique est portée par l'attribut HTML natif `required` |
## Section 1 — Indicateur « obligatoire » ## Section 1 — Indicateur « obligatoire »
@@ -63,11 +63,25 @@ L'astérisque vit **à l'intérieur du `<label>`** → il flotte avec le floatin
### Props à ajouter ### Props à ajouter
`required?: boolean` (défaut `false`) sur les 5 composants qui ne l'ont pas : `Select`, `SelectCheckbox`, `InputUpload`, `InputRichText`, `SiteSelector`. Câblage sur l'attribut natif quand le composant a un élément de formulaire sous-jacent qui le supporte. `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 ### Composants concernés par le rendu de l'astérisque
Famille formulaire complète : `InputText`, `InputEmail`, `InputPhone`, `InputPassword`, `InputTextArea`, `InputAmount`, `InputNumber`, `InputAutocomplete`, `InputUpload`, `InputRichText`, `Select`, `SelectCheckbox`, `Checkbox`, `RadioButton`, `Date`, `DateTime`, `DateRange`, `DateWeek`, `Time`, `TimePicker`, `SiteSelector`. `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 ### Alternative écartée
@@ -98,10 +112,19 @@ const onInput = (event: Event) => {
const sanitized = sanitizeEmail(raw) const sanitized = sanitizeEmail(raw)
if (sanitized !== raw) { if (sanitized !== raw) {
const caret = target.selectionStart ?? raw.length // `<input type="email">` ne supporte PAS l'API de sélection :
const newCaret = sanitizeEmail(raw.slice(0, caret)).length // 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 target.value = sanitized
target.setSelectionRange(newCaret, newCaret) 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 if (!isControlled.value) localValue.value = sanitized
@@ -111,8 +134,8 @@ const onInput = (event: Event) => {
Points clés : Points clés :
- **Curseur** : position recalculée en sanitisant la portion à gauche du curseur → taper un espace au milieu ne fait pas sauter le curseur. `lowercase` ne change pas la longueur, donc n'affecte pas la position.
- **Resynchro DOM** : `target.value = sanitized` même en mode contrôlé, pour que l'affichage colle toujours à la valeur émise. - **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`). - **Collage** couvert (paste déclenche `input`).
- **Inchangé** : `type="email"`, `inputmode="email"`, icône, et **aucune validation de format**. - **Inchangé** : `type="email"`, `inputmode="email"`, icône, et **aucune validation de format**.