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:
@@ -23,6 +23,8 @@ type InputEmailProps = {
|
||||
iconPosition?: 'left' | 'right'
|
||||
iconSize?: string | number
|
||||
iconColor?: string
|
||||
lowercase?: boolean
|
||||
reserveMessageSpace?: boolean
|
||||
}
|
||||
|
||||
const InputEmailForTest = InputEmail as DefineComponent<InputEmailProps>
|
||||
@@ -52,6 +54,16 @@ describe('MalioInputEmail', () => {
|
||||
expect(wrapper.get('label').text()).toBe('Adresse email')
|
||||
})
|
||||
|
||||
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 email', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
@@ -225,4 +237,82 @@ describe('MalioInputEmail', () => {
|
||||
|
||||
expect(wrapper.get('input').attributes('autocomplete')).toBe('email')
|
||||
})
|
||||
|
||||
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'])
|
||||
})
|
||||
|
||||
it('émet la valeur sanitisée en mode contrôlé', async () => {
|
||||
const wrapper = mountComponent({modelValue: ''})
|
||||
await wrapper.get('input').setValue(' a b @ c.com ')
|
||||
expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual(['ab@c.com'])
|
||||
})
|
||||
|
||||
it('resynchronise le DOM en mode contrôlé même quand la valeur sanitisée égale déjà modelValue', async () => {
|
||||
// L'utilisateur ajoute un espace en fin alors que la valeur nettoyée vaut déjà modelValue.
|
||||
// Le parent ne « changera » pas modelValue → Vue ne re-patche pas le DOM ; l'écriture
|
||||
// manuelle target.value = sanitized est donc indispensable pour retirer l'espace affiché.
|
||||
const wrapper = mountComponent({modelValue: 'ab@c.com'})
|
||||
const input = wrapper.get('input')
|
||||
await input.setValue('ab@c.com ')
|
||||
expect(input.element.value).toBe('ab@c.com')
|
||||
})
|
||||
|
||||
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
|
||||
const wrapper = mountComponent({label: 'Champ', readonly: true})
|
||||
const field = wrapper.get('input')
|
||||
expect(field.classes()).toContain('border-black')
|
||||
expect(field.classes()).not.toContain('border-m-muted')
|
||||
expect(field.classes()).not.toContain('focus:border-m-primary')
|
||||
expect(field.classes()).not.toContain('grow-height')
|
||||
})
|
||||
|
||||
it('readonly vide : label gris, pas de bleu', () => {
|
||||
const wrapper = mountComponent({label: 'Champ', readonly: true})
|
||||
expect(wrapper.get('label').classes()).not.toContain('peer-focus:text-m-primary')
|
||||
expect(wrapper.get('label').classes()).toContain('text-m-muted')
|
||||
})
|
||||
|
||||
it('readonly rempli : label noir et icône noire', () => {
|
||||
const wrapper = mountComponent({label: 'Champ', readonly: true, modelValue: 'user@example.com'})
|
||||
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
|
||||
})
|
||||
|
||||
it('réserve l’espace 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]')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user