Files
malio-layer-ui/app/components/malio/input/InputRichText.test.ts
T
tristan 887ebdebd7 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>
2026-06-04 06:42:19 +00:00

211 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {afterEach, describe, expect, it} from 'vitest'
import {flushPromises, mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import InputRichText from './InputRichText.vue'
type InputRichTextProps = {
id?: string
label?: string
modelValue?: string | null
placeholder?: string
minHeight?: string
editable?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
outputFormat?: 'markdown' | 'html'
groupClass?: string
labelClass?: string
editorClass?: string
required?: boolean
reserveMessageSpace?: boolean
}
const InputRichTextForTest = InputRichText as DefineComponent<InputRichTextProps>
const mountComponent = async (props: InputRichTextProps = {}) => {
const wrapper = mount(InputRichTextForTest, {
props,
attachTo: document.body,
})
await flushPromises()
return wrapper
}
afterEach(() => {
document.body.replaceChildren()
})
describe('MalioInputRichText', () => {
it('renders the label and reuses a provided id', async () => {
const wrapper = await mountComponent({id: 'custom-rt-id', label: 'Description'})
const label = wrapper.get('label')
expect(label.text()).toBe('Description')
expect(label.attributes('for')).toBe('custom-rt-id')
expect(wrapper.get('#custom-rt-id').exists()).toBe(true)
})
it('generates an id when missing', async () => {
const wrapper = await mountComponent({label: 'Description'})
const labelFor = wrapper.get('label').attributes('for')
expect(labelFor?.startsWith('malio-input-rich-text-')).toBe(true)
})
it('renders the toolbar buttons in editable mode', async () => {
const wrapper = await mountComponent({modelValue: ''})
const buttons = wrapper.findAll('button[type="button"]')
expect(buttons.length).toBeGreaterThanOrEqual(13)
expect(wrapper.find('button[title="Gras"]').exists()).toBe(true)
expect(wrapper.find('button[title="Italique"]').exists()).toBe(true)
expect(wrapper.find('button[title="Lien"]').exists()).toBe(true)
expect(wrapper.find('button[title="Couleur du texte"]').exists()).toBe(true)
expect(wrapper.find('button[title="Surlignage"]').exists()).toBe(true)
expect(wrapper.find('button[title="Annuler"]').exists()).toBe(true)
expect(wrapper.find('button[title="Rétablir"]').exists()).toBe(true)
})
it('opens and closes the text color palette', async () => {
const wrapper = await mountComponent({modelValue: ''})
expect(wrapper.find('[aria-label="Palette couleur du texte"]').exists()).toBe(false)
await wrapper.get('button[title="Couleur du texte"]').trigger('click')
expect(wrapper.find('[aria-label="Palette couleur du texte"]').exists()).toBe(true)
await wrapper.get('button[title="Couleur du texte"]').trigger('click')
expect(wrapper.find('[aria-label="Palette couleur du texte"]').exists()).toBe(false)
})
it('opens the highlight palette and closes the color palette', async () => {
const wrapper = await mountComponent({modelValue: ''})
await wrapper.get('button[title="Couleur du texte"]').trigger('click')
expect(wrapper.find('[aria-label="Palette couleur du texte"]').exists()).toBe(true)
await wrapper.get('button[title="Surlignage"]').trigger('click')
expect(wrapper.find('[aria-label="Palette de surlignage"]').exists()).toBe(true)
expect(wrapper.find('[aria-label="Palette couleur du texte"]').exists()).toBe(false)
})
it('disables color and highlight buttons when readonly', async () => {
const wrapper = await mountComponent({readonly: true, modelValue: ''})
expect(wrapper.get('button[title="Couleur du texte"]').attributes('disabled')).toBeDefined()
expect(wrapper.get('button[title="Surlignage"]').attributes('disabled')).toBeDefined()
})
it('does not render the toolbar in readonly display mode (editable=false)', async () => {
const wrapper = await mountComponent({editable: false, modelValue: '**hi**'})
expect(wrapper.find('button[title="Gras"]').exists()).toBe(false)
})
it('disables toolbar buttons when disabled', async () => {
const wrapper = await mountComponent({disabled: true, modelValue: ''})
const boldBtn = wrapper.get('button[title="Gras"]')
expect(boldBtn.attributes('disabled')).toBeDefined()
})
it('disables toolbar buttons when readonly', async () => {
const wrapper = await mountComponent({readonly: true, modelValue: ''})
const boldBtn = wrapper.get('button[title="Gras"]')
expect(boldBtn.attributes('disabled')).toBeDefined()
})
it('shows hint message in muted color', async () => {
const wrapper = await mountComponent({hint: 'Helpful hint'})
expect(wrapper.get('p.text-m-muted').text()).toBe('Helpful hint')
})
it('shows error state on wrapper, label and message', async () => {
const wrapper = await mountComponent({label: 'Description', error: 'Editor error'})
expect(wrapper.get('label').classes()).toContain('text-m-danger')
expect(wrapper.get('p.text-m-danger').text()).toBe('Editor error')
expect(wrapper.get('.rich-text-wrapper').classes()).toContain('border-m-danger')
})
it('shows success state on wrapper, label and message', async () => {
const wrapper = await mountComponent({label: 'Description', success: 'Editor success'})
expect(wrapper.get('label').classes()).toContain('text-m-success')
expect(wrapper.get('p.text-m-success').text()).toBe('Editor success')
expect(wrapper.get('.rich-text-wrapper').classes()).toContain('border-m-success')
})
it('prioritizes error over success', async () => {
const wrapper = await mountComponent({error: 'Editor error', success: 'Editor success'})
expect(wrapper.get('.rich-text-wrapper').classes()).toContain('border-m-danger')
expect(wrapper.find('p.text-m-success').exists()).toBe(false)
expect(wrapper.get('p.text-m-danger').text()).toBe('Editor error')
})
it('sets aria-invalid and aria-describedby on the editor content when error', async () => {
const wrapper = await mountComponent({id: 'rt-aria', error: 'Boom'})
const editorContent = wrapper.find('[aria-invalid="true"]')
expect(editorContent.exists()).toBe(true)
expect(editorContent.attributes('aria-describedby')).toBe('rt-aria-describedby')
})
it('expose aria-required quand required est vrai', async () => {
const wrapper = await mountComponent({required: true})
expect(wrapper.find('[aria-required="true"]').exists()).toBe(true)
})
it('n\'expose pas aria-required par défaut', async () => {
const wrapper = await mountComponent()
expect(wrapper.find('[aria-required="true"]').exists()).toBe(false)
})
it('renders initial markdown content visually', async () => {
const wrapper = await mountComponent({modelValue: '## Mon titre\n\nUn paragraphe.'})
const html = wrapper.html()
expect(html).toContain('Mon titre')
expect(html).toContain('Un paragraphe.')
})
it('affiche l\'astérisque quand required est vrai', async () => {
const wrapper = await 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', async () => {
const wrapper = await mountComponent({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('réserve lespace message par défaut même sans message', async () => {
const wrapper = await 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', async () => {
const wrapper = await mountComponent({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', async () => {
const wrapper = await 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]')
})
})