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
+65 -2
View File
@@ -27,6 +27,7 @@ type InputPhoneProps = {
addable?: boolean
addIconName?: string
addButtonLabel?: string
reserveMessageSpace?: boolean
}
const InputPhoneForTest = InputPhone as DefineComponent<InputPhoneProps>
@@ -56,6 +57,16 @@ describe('MalioInputPhone', () => {
expect(wrapper.get('label').text()).toBe('Téléphone')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mountComponent({label: 'Champ', required: true})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
})
it('n\'affiche pas l\'astérisque par défaut', () => {
const wrapper = mountComponent({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('has type tel', () => {
const wrapper = mountComponent()
@@ -264,10 +275,43 @@ describe('MalioInputPhone', () => {
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeDefined()
})
it('disables add button when readonly', () => {
it('add button is not natively disabled in readonly (onAdd guard blocks the action)', () => {
const wrapper = mountComponent({addable: true, readonly: true})
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeDefined()
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeUndefined()
})
it('readonly : border-black appliqué sur l\'input', () => {
const wrapper = mountComponent({label: 'Tel', readonly: true})
expect(wrapper.get('input').classes()).toContain('border-black')
})
it('readonly : icône en text-m-muted quand vide', () => {
const wrapper = mountComponent({label: 'Tel', readonly: true})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
})
it('readonly : icône en text-black quand rempli', () => {
const wrapper = mountComponent({label: 'Tel', readonly: true, modelValue: '+33612345678'})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('readonly : pas d\'apparence désactivée (pas opacity-40)', () => {
const wrapper = mountComponent({label: 'Tel', addable: true, readonly: true})
// opacity-40 was only ever applied to the add button, not the input
expect(wrapper.get('[data-test="add-button"]').classes()).not.toContain('opacity-40')
// and the input is not natively disabled in readonly:
expect(wrapper.get('input').attributes('disabled')).toBeUndefined()
})
it('readonly vide : label en text-m-muted', () => {
const wrapper = mountComponent({label: 'Tel', readonly: true})
expect(wrapper.get('label').classes()).toContain('text-m-muted')
})
it('readonly rempli : label en text-black', () => {
const wrapper = mountComponent({label: 'Tel', readonly: true, modelValue: '+33612345678'})
expect(wrapper.get('label').classes()).toContain('text-black')
})
it('renders the default add icon (mdi:plus)', () => {
@@ -340,4 +384,23 @@ describe('MalioInputPhone', () => {
expect(wrapper.emitted('update:modelValue')).toBeDefined()
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountComponent({label: 'Champ'})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).toContain('min-h-[1rem]')
})
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
const wrapper = mountComponent({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mountComponent({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).not.toContain('min-h-[1rem]')
})
})