Files
malio-layer-ui/app/components/malio/input/Input.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

379 lines
14 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 {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import Input from './InputText.vue'
type InputProps = {
id?: string
label?: string
name?: string
autocomplete?: string
modelValue?: string | null
inputClass?: string
labelClass?: string
groupClass?: string
required?: boolean
maxLength?: number | string
minLength?: number | string
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
iconName?: string
iconPosition?: 'left' | 'right'
iconSize?: string | number
iconColor?: string
reserveMessageSpace?: boolean
}
const InputForTest = Input as DefineComponent<InputProps>
const mountInput = (props: InputProps = {}) =>
mount(InputForTest, {
props,
global: {
stubs: {
IconifyIcon: {
template: '<span data-test="icon" v-bind="$attrs" />',
},
},
},
})
describe('MalioInputText', () => {
it('renders the initial input value', () => {
const wrapper = mountInput({modelValue: 'initialValueTest'})
expect(wrapper.get('input').element.value).toBe('initialValueTest')
})
it('renders the label text', () => {
const wrapper = mountInput({label: 'labelTest'})
expect(wrapper.get('label').text()).toBe('labelTest')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mountInput({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 = mountInput({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('applies the name attribute', () => {
const wrapper = mountInput({name: 'nameTest'})
expect(wrapper.get('input').attributes('name')).toBe('nameTest')
})
it('uses provided id on input and label', () => {
const wrapper = mountInput({id: 'custom-id', label: 'Label'})
expect(wrapper.get('input').attributes('id')).toBe('custom-id')
expect(wrapper.get('label').attributes('for')).toBe('custom-id')
})
it('keeps the default rounded class on input', () => {
const wrapper = mountInput()
expect(wrapper.get('input').classes()).toContain('rounded-md')
})
it('generates an id when missing and reuses it on label', () => {
const wrapper = mountInput({label: 'Label'})
const inputId = wrapper.get('input').attributes('id')
expect(inputId?.startsWith('malio-input-text-')).toBe(true)
expect(wrapper.get('label').attributes('for')).toBe(inputId)
})
it('applies the autocomplete attribute', () => {
const wrapper = mountInput({autocomplete: 'autocompleteTest'})
expect(wrapper.get('input').attributes('autocomplete')).toBe('autocompleteTest')
})
it('does not set required when false', () => {
const wrapper = mountInput({required: false})
expect(wrapper.get('input').attributes('required')).toBeUndefined()
})
it('sets required when true', () => {
const wrapper = mountInput({required: true})
expect(wrapper.get('input').attributes('required')).toBeDefined()
})
it('does not set readonly when false', () => {
const wrapper = mountInput({readonly: false})
expect(wrapper.get('input').attributes('readonly')).toBeUndefined()
})
it('sets readonly when true', () => {
const wrapper = mountInput({readonly: true})
expect(wrapper.get('input').attributes('readonly')).toBeDefined()
})
it('does not set disabled and keeps text cursor when false', () => {
const wrapper = mountInput({disabled: false})
expect(wrapper.get('input').attributes('disabled')).toBeUndefined()
expect(wrapper.get('input').classes()).toContain('cursor-text')
})
it('sets disabled styles when true', () => {
const wrapper = mountInput({disabled: true})
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
expect(wrapper.get('input').classes()).toContain('cursor-not-allowed')
expect(wrapper.get('input').classes()).toContain('text-black/60')
})
it('shows muted label color when disabled (matches border color)', () => {
const wrapper = mountInput({label: 'Email', disabled: true, modelValue: 'foo@bar.com'})
expect(wrapper.get('label').classes()).toContain('text-m-muted')
expect(wrapper.get('label').classes()).not.toContain('text-black/60')
})
it('emits update:modelValue on input change', async () => {
const wrapper = mountInput({modelValue: ''})
await wrapper.get('input').setValue('new value')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['new value'])
})
it('applies maxLength to input', () => {
const wrapper = mountInput({maxLength: 25})
expect(wrapper.get('input').attributes('maxlength')).toBe('25')
})
it('applies minLength to input', () => {
const wrapper = mountInput({minLength: 25})
expect(wrapper.get('input').attributes('minlength')).toBe('25')
})
it('applies labelClass on label', () => {
const wrapper = mountInput({label: 'Label', labelClass: 'text-red-500'})
expect(wrapper.get('label').classes()).toContain('text-red-500')
})
it('applies inputClass on input', () => {
const wrapper = mountInput({inputClass: 'text-sm'})
expect(wrapper.get('input').classes()).toContain('text-sm')
})
it('shows error message without label and icon', () => {
const wrapper = mountInput({error: 'Error message test'})
expect(wrapper.get('p.text-m-danger').text()).toBe('Error message test')
expect(wrapper.get('input').classes()).toContain('border-m-danger')
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
expect(wrapper.get('p').classes()).toContain('text-m-danger')
})
it('shows error message with label and without icon', () => {
const wrapper = mountInput({error: 'Error message test', label: 'Error message'})
expect(wrapper.get('p.text-m-danger').text()).toBe('Error message test')
expect(wrapper.get('input').classes()).toContain('border-m-danger')
expect(wrapper.get('label').classes()).toContain('text-m-danger')
expect(wrapper.get('p').classes()).toContain('text-m-danger')
})
it('shows error message with label and icon', () => {
const wrapper = mountInput({
error: 'Error message test',
label: 'Error message',
iconName: 'mdi:key-outline',
})
expect(wrapper.get('p.text-m-danger').text()).toBe('Error message test')
expect(wrapper.get('input').classes()).toContain('border-m-danger')
expect(wrapper.get('label').classes()).toContain('text-m-danger')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-danger')
expect(wrapper.get('p').classes()).toContain('text-m-danger')
})
it('shows error message with icon and without label', () => {
const wrapper = mountInput({error: 'Error message test', iconName: 'mdi:key-outline'})
expect(wrapper.get('p.text-m-danger').text()).toBe('Error message test')
expect(wrapper.get('input').classes()).toContain('border-m-danger')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-danger')
})
it('shows success message without label and icon', () => {
const wrapper = mountInput({success: 'Success message test'})
expect(wrapper.get('p.text-m-success').text()).toBe('Success message test')
expect(wrapper.get('input').classes()).toContain('border-m-success')
})
it('shows success message with label and without icon', () => {
const wrapper = mountInput({success: 'Success message test', label: 'Success message'})
expect(wrapper.get('p.text-m-success').text()).toBe('Success message test')
expect(wrapper.get('input').classes()).toContain('border-m-success')
expect(wrapper.get('label').classes()).toContain('text-m-success')
})
it('shows success message with label and icon', () => {
const wrapper = mountInput({
success: 'Success message test',
label: 'Success message',
iconName: 'mdi:key-outline',
})
expect(wrapper.get('p.text-m-success').text()).toBe('Success message test')
expect(wrapper.get('input').classes()).toContain('border-m-success')
expect(wrapper.get('label').classes()).toContain('text-m-success')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-success')
})
it('shows success message with icon and without label', () => {
const wrapper = mountInput({success: 'Success message test', iconName: 'mdi:key-outline'})
expect(wrapper.get('p.text-m-success').text()).toBe('Success message test')
expect(wrapper.get('input').classes()).toContain('border-m-success')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-success')
})
it('prioritizes error over success when both are provided', () => {
const wrapper = mountInput({
error: 'Error message test',
success: 'Success message test',
})
expect(wrapper.find('p.text-m-danger').exists()).toBe(true)
expect(wrapper.get('p.text-m-danger').text()).toBe('Error message test')
expect(wrapper.find('p.text-m-success').exists()).toBe(false)
expect(wrapper.get('input').classes()).toContain('border-m-danger')
expect(wrapper.get('input').classes()).not.toContain('border-m-success')
})
it('shows hint message', () => {
const wrapper = mountInput({hint: 'Hint message test'})
expect(wrapper.get('p.text-m-muted').text()).toBe('Hint message test')
})
it('reserves space for the message even when no hint/error/success is set', () => {
const wrapper = mountInput({})
const p = wrapper.find('p')
expect(p.exists()).toBe(true)
expect(p.text()).toBe('')
expect(p.classes()).toContain('min-h-[1rem]')
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountInput({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 = mountInput({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mountInput({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]')
})
it('does not render label when label prop is missing', () => {
const wrapper = mountInput({labelClass: 'text-red-500'})
expect(wrapper.find('label').exists()).toBe(false)
})
it('renders icon with default positioning and muted color', () => {
const wrapper = mountInput({iconName: 'mdi:key-outline'})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('pointer-events-none')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('absolute')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('right-[10px]')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('top-1/2')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('-translate-y-1/2')
})
it('renders icon on the left when requested', () => {
const wrapper = mountInput({
iconName: 'mdi:key-outline',
iconPosition: 'left',
label: 'Password',
})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('left-[10px]')
expect(wrapper.get('input').classes()).toContain('!pl-11')
expect(wrapper.get('label').classes()).toContain('left-11')
})
it('passes icon size props to icon component', () => {
const wrapper = mountInput({iconName: 'mdi:key-outline', iconSize: '24'})
expect(wrapper.get('[data-test="icon"]').attributes('width')).toBe('24')
expect(wrapper.get('[data-test="icon"]').attributes('height')).toBe('24')
})
it('applies icon color class', () => {
const wrapper = mountInput({iconName: 'mdi:key-outline', iconColor: 'text-m-primary'})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary')
})
it('shows primary icon color on focus', async () => {
const wrapper = mountInput({iconName: 'mdi:key-outline'})
await wrapper.get('input').trigger('focus')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary')
})
it('shows black icon color when filled and unfocused', () => {
const wrapper = mountInput({iconName: 'mdi:key-outline', modelValue: 'hello'})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
const wrapper = mountInput({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 = mountInput({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 = mountInput({label: 'Champ', readonly: true, modelValue: 'hello', iconName: 'mdi:key-outline'})
expect(wrapper.get('label').classes()).toContain('text-black')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
})