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:
@@ -1,5 +1,5 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import {mount, renderToString} from '@vue/test-utils'
|
||||
import type {DefineComponent} from 'vue'
|
||||
import SelectCheckbox from './SelectCheckbox.vue'
|
||||
|
||||
@@ -9,7 +9,7 @@ type Option = {
|
||||
}
|
||||
|
||||
type SelectCheckboxProps = {
|
||||
modelValue: Array<string | number>
|
||||
modelValue?: Array<string | number>
|
||||
options?: Option[]
|
||||
emptyOptionLabel?: string
|
||||
label?: string
|
||||
@@ -24,7 +24,10 @@ type SelectCheckboxProps = {
|
||||
displaySelectAll?: boolean
|
||||
selectAllLabel?: string
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
groupClass?: string
|
||||
required?: boolean
|
||||
reserveMessageSpace?: boolean
|
||||
}
|
||||
|
||||
const SelectCheckboxForTest = SelectCheckbox as DefineComponent<SelectCheckboxProps>
|
||||
@@ -36,6 +39,18 @@ const options: Option[] = [
|
||||
]
|
||||
|
||||
describe('MalioSelectCheckbox', () => {
|
||||
it('rend sans planter quand modelValue n’est pas fourni (non contrôlé)', () => {
|
||||
expect(() =>
|
||||
mount(SelectCheckboxForTest, {props: {label: 'Catégories', options}}),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('rend en SSR sans planter quand modelValue est absent (cause du crash playground)', async () => {
|
||||
await expect(
|
||||
renderToString(SelectCheckboxForTest, {props: {label: 'Catégories', readonly: true, options}}),
|
||||
).resolves.toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders checkbox inputs for options', async () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {modelValue: [], options},
|
||||
@@ -235,6 +250,38 @@ describe('MalioSelectCheckbox', () => {
|
||||
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-success')
|
||||
})
|
||||
|
||||
it('affiche l\'astérisque quand required est vrai', () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {modelValue: [], 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 = mount(SelectCheckboxForTest, {
|
||||
props: {modelValue: [], label: 'Champ'},
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('expose aria-required quand required est vrai', () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {modelValue: [], options, required: true},
|
||||
})
|
||||
|
||||
expect(wrapper.find('[aria-required="true"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('n\'expose pas aria-required par défaut', () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {modelValue: [], options},
|
||||
})
|
||||
|
||||
expect(wrapper.find('[aria-required="true"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the bottom border allocation when open downward (transparent, not zero)', async () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {modelValue: [], options},
|
||||
@@ -248,4 +295,75 @@ describe('MalioSelectCheckbox', () => {
|
||||
expect(buttonClasses).not.toContain('!border-b-0')
|
||||
expect(buttonClasses).toContain('!border-b-transparent')
|
||||
})
|
||||
|
||||
it('readonly : bordure noire même sans sélection, pas de grow/bleu', () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {label: 'Champ', readonly: true, modelValue: [], options: [{label: 'A', value: 'a'}]},
|
||||
})
|
||||
const trigger = wrapper.get('button')
|
||||
expect(trigger.classes()).toContain('border-black')
|
||||
expect(trigger.classes()).not.toContain('border-m-muted')
|
||||
expect(trigger.classes()).not.toContain('grow-height')
|
||||
expect(trigger.classes()).not.toContain('focus-visible:border-m-primary')
|
||||
})
|
||||
|
||||
it('readonly vide : label gris, pas de bleu', () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {label: 'Champ', readonly: true, modelValue: [], options: [{label: 'A', value: 'a'}]},
|
||||
})
|
||||
const label = wrapper.get('label')
|
||||
expect(label.classes()).not.toContain('text-m-primary')
|
||||
expect(label.classes()).toContain('text-m-muted')
|
||||
})
|
||||
|
||||
it('readonly sélectionné : label noir + chevron noir', () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {label: 'Champ', readonly: true, modelValue: ['a'], options: [{label: 'A', value: 'a'}]},
|
||||
})
|
||||
expect(wrapper.get('label').classes()).toContain('text-black')
|
||||
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
|
||||
})
|
||||
|
||||
it('readonly empêche l’ouverture du dropdown', async () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {label: 'Champ', readonly: true, modelValue: [], options: [{label: 'A', value: 'a'}]},
|
||||
})
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('readonly expose aria-readonly et reste focusable (pas disabled)', () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {
|
||||
props: {label: 'Champ', readonly: true, modelValue: [], options},
|
||||
})
|
||||
const trigger = wrapper.get('button')
|
||||
expect(trigger.attributes('aria-readonly')).toBe('true')
|
||||
expect(trigger.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disabled + readonly : pas d’aria-readonly (disabled prime)', () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {props: {modelValue: [], label: 'Champ', disabled: true, readonly: true, options: [{label: 'A', value: 'a'}]}})
|
||||
const trigger = wrapper.get('button')
|
||||
expect(trigger.attributes('aria-readonly')).toBeUndefined()
|
||||
expect(trigger.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('réserve l’espace message par défaut même sans message', () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {props: {label: 'Champ', options}})
|
||||
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 = mount(SelectCheckboxForTest, {props: {label: 'Champ', options, reserveMessageSpace: false}})
|
||||
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
|
||||
const wrapper = mount(SelectCheckboxForTest, {props: {label: 'Champ', options, 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