feat(ui) : prop reserveMessageSpace (défaut true) sur la famille input

Ajoute une prop booléenne reserveMessageSpace (défaut true) aux 10 composants
de la famille input. Par défaut, comportement inchangé (ligne message toujours
rendue avec min-h-[1rem]). À false, la ligne ne prend aucun espace en l'absence
de message, et s'affiche sans min-h quand un message est présent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 16:43:31 +02:00
parent 5f1dc834cd
commit cda0f994ca
20 changed files with 253 additions and 10 deletions
+20
View File
@@ -24,6 +24,7 @@ type InputProps = {
iconPosition?: 'left' | 'right'
iconSize?: string | number
iconColor?: string
reserveMessageSpace?: boolean
}
const InputForTest = Input as DefineComponent<InputProps>
@@ -279,6 +280,25 @@ describe('MalioInputText', () => {
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'})
@@ -24,6 +24,7 @@ type InputAmountProps = {
iconPosition?: 'left' | 'right'
iconSize?: string | number
iconColor?: string
reserveMessageSpace?: boolean
}
const InputAmountForTest = InputAmount as DefineComponent<InputAmountProps>
@@ -210,4 +211,23 @@ describe('MalioInputAmount', () => {
expect(wrapper.get('label').classes()).toContain('text-black')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountInputAmount({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 = mountInputAmount({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mountInputAmount({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]')
})
})
+5 -1
View File
@@ -44,6 +44,7 @@
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
:id="`${inputId}-describedby`"
:class="[
hasError
@@ -51,7 +52,8 @@
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px] min-h-[1rem]',
'mt-1 text-xs ml-[2px]',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]"
>
{{ hint || error || success }}
@@ -89,6 +91,7 @@ const props = withDefaults(
iconPosition?: 'left' | 'right'
iconSize?: string | number
iconColor?: string
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -111,6 +114,7 @@ const props = withDefaults(
success: '',
iconSize: 20,
iconColor: 'text-m-muted',
reserveMessageSpace: true,
},
)
@@ -36,6 +36,7 @@ type InputAutocompleteProps = {
noResultsText?: string
loadingText?: string
minSearchText?: string
reserveMessageSpace?: boolean
}
const InputAutocompleteForTest = InputAutocomplete as DefineComponent<InputAutocompleteProps>
@@ -543,4 +544,23 @@ describe('MalioInputAutocomplete', () => {
expect(wrapper.get('[data-test="icon-left"]').classes()).toContain('text-black')
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountComponent({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 = mountComponent({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 = mountComponent({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]')
})
})
@@ -136,10 +136,12 @@
</ul>
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
:id="`${inputId}-describedby`"
:class="[
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
'mt-1 ml-[2px] text-xs min-h-[1rem]',
'mt-1 ml-[2px] text-xs',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]"
>
{{ hint || error || success }}
@@ -188,6 +190,7 @@ const props = withDefaults(
noResultsText?: string
loadingText?: string
minSearchText?: string
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -216,6 +219,7 @@ const props = withDefaults(
noResultsText: 'Aucun résultat',
loadingText: 'Chargement…',
minSearchText: 'Tapez pour rechercher',
reserveMessageSpace: true,
},
)
@@ -24,6 +24,7 @@ type InputEmailProps = {
iconSize?: string | number
iconColor?: string
lowercase?: boolean
reserveMessageSpace?: boolean
}
const InputEmailForTest = InputEmail as DefineComponent<InputEmailProps>
@@ -295,4 +296,23 @@ describe('MalioInputEmail', () => {
expect(wrapper.get('label').classes()).toContain('text-black')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
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]')
})
})
+5 -1
View File
@@ -42,6 +42,7 @@
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
:id="`${inputId}-describedby`"
:class="[
hasError
@@ -49,7 +50,8 @@
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px] min-h-[1rem]',
'mt-1 text-xs ml-[2px]',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]"
>
{{ hint || error || success }}
@@ -87,6 +89,7 @@ const props = withDefaults(
iconSize?: string | number
iconColor?: string
lowercase?: boolean
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -108,6 +111,7 @@ const props = withDefaults(
iconSize: 24,
iconColor: 'text-m-muted',
lowercase: false,
reserveMessageSpace: true,
},
)
@@ -10,6 +10,9 @@ type InputNumberProps = {
readonly?: boolean
min?: number | string
max?: number | string
error?: string
hint?: string
reserveMessageSpace?: boolean
}
const InputNumberForTest = InputNumber as DefineComponent<InputNumberProps>
@@ -173,4 +176,23 @@ describe('MalioInputNumber', () => {
const wrapper = mountInputNumber({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountInputNumber({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 = mountInputNumber({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mountInputNumber({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]')
})
})
+5 -1
View File
@@ -51,6 +51,7 @@
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
:id="`${inputId}-describedby`"
:class="[
hasError
@@ -58,7 +59,8 @@
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'text-xs ml-[2px] min-h-[1rem]',
'text-xs ml-[2px]',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]"
>
{{ hint || error || success }}
@@ -91,6 +93,7 @@ const props = withDefaults(
hint?: string
error?: string
success?: string
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -108,6 +111,7 @@ const props = withDefaults(
hint: '',
error: '',
success: '',
reserveMessageSpace: true,
},
)
@@ -22,6 +22,7 @@ type InputPasswordProps = {
error?: string
success?: string
displayIcon?: boolean
reserveMessageSpace?: boolean
}
const InputPasswordForTest = InputPassword as DefineComponent<InputPasswordProps>
@@ -227,4 +228,23 @@ describe('MalioInputPassword', () => {
await wrapper.get('[data-test="icon"]').trigger('click')
expect(wrapper.get('input').attributes('type')).toBe('text')
})
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]')
})
})
+5 -1
View File
@@ -47,6 +47,7 @@
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
:id="`${inputId}-describedby`"
:class="[
hasError
@@ -54,7 +55,8 @@
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px] min-h-[1rem]',
'mt-1 text-xs ml-[2px]',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]"
>
{{ hint || error || success }}
@@ -90,6 +92,7 @@ const props = withDefaults(
error?: string
success?: string
displayIcon?: boolean
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -109,6 +112,7 @@ const props = withDefaults(
error: '',
success: '',
displayIcon: true,
reserveMessageSpace: true,
},
)
@@ -27,6 +27,7 @@ type InputPhoneProps = {
addable?: boolean
addIconName?: string
addButtonLabel?: string
reserveMessageSpace?: boolean
}
const InputPhoneForTest = InputPhone as DefineComponent<InputPhoneProps>
@@ -383,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]')
})
})
+5 -1
View File
@@ -60,6 +60,7 @@
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
:id="`${inputId}-describedby`"
:class="[
hasError
@@ -67,7 +68,8 @@
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px] min-h-[1rem]',
'mt-1 text-xs ml-[2px]',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]"
>
{{ hint || error || success }}
@@ -110,6 +112,7 @@ const props = withDefaults(
addable?: boolean
addIconName?: string
addButtonLabel?: string
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -134,6 +137,7 @@ const props = withDefaults(
addable: false,
addIconName: 'mdi:plus',
addButtonLabel: 'Ajouter un numéro',
reserveMessageSpace: true,
},
)
@@ -20,6 +20,7 @@ type InputRichTextProps = {
labelClass?: string
editorClass?: string
required?: boolean
reserveMessageSpace?: boolean
}
const InputRichTextForTest = InputRichText as DefineComponent<InputRichTextProps>
@@ -187,4 +188,23 @@ describe('MalioInputRichText', () => {
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]')
})
})
+5 -1
View File
@@ -185,6 +185,7 @@
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
:id="`${editorId}-describedby`"
:class="[
hasError
@@ -192,7 +193,8 @@
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px] min-h-[1rem]',
'mt-1 text-xs ml-[2px]',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]"
>
{{ error || success || hint }}
@@ -235,6 +237,7 @@ const props = withDefaults(
labelClass?: string
editorClass?: string
required?: boolean
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -253,6 +256,7 @@ const props = withDefaults(
labelClass: '',
editorClass: '',
required: false,
reserveMessageSpace: true,
},
)
+5 -1
View File
@@ -44,6 +44,7 @@
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
:id="`${inputId}-describedby`"
:class="[
hasError
@@ -51,7 +52,8 @@
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px] min-h-[1rem]',
'mt-1 text-xs ml-[2px]',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]"
>
{{ hint || error || success }}
@@ -94,6 +96,7 @@ const props = withDefaults(
iconSize?: string | number
iconColor?: string
mask?: string | MaskInputOptions
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -117,6 +120,7 @@ const props = withDefaults(
iconSize: 24,
iconColor: 'text-m-muted',
mask: undefined,
reserveMessageSpace: true,
},
)
@@ -21,6 +21,7 @@ type InputTextAreaProps = {
error?: string
success?: string
rounded?: string
reserveMessageSpace?: boolean
}
const InputTextAreaForTest = InputTextArea as DefineComponent<InputTextAreaProps>
@@ -213,4 +214,23 @@ describe('MalioInputTextArea', () => {
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', readonly: true, modelValue: 'du texte'}})
expect(wrapper.get('label').classes()).toContain('text-black')
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ'}})
const msg = wrapper.find('[data-test="message-line"]')
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(InputTextAreaForTest, {props: {label: 'Champ', reserveMessageSpace: false}})
expect(wrapper.find('[data-test="message-line"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', reserveMessageSpace: false, error: 'Erreur'}})
const msg = wrapper.find('[data-test="message-line"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).not.toContain('min-h-[1rem]')
})
})
+6 -1
View File
@@ -63,7 +63,10 @@
</span>
</div>
<div
class="mt-1 flex items-center justify-between gap-2 text-xs min-h-[1rem]"
v-if="reserveMessageSpace || hint || error || success"
data-test="message-line"
class="mt-1 flex items-center justify-between gap-2 text-xs"
:class="reserveMessageSpace ? 'min-h-[1rem]' : ''"
>
<p
:id="`${inputId}-describedby`"
@@ -114,6 +117,7 @@ const props = withDefaults(
success?: string
rounded?: string
groupClass?: string
reserveMessageSpace?: boolean
}>(),
{
@@ -140,6 +144,7 @@ const props = withDefaults(
minResizeHeight: 40,
maxResizeHeight: 320,
groupClass: '',
reserveMessageSpace: true,
},
)
@@ -19,6 +19,7 @@ type InputUploadProps = {
displayIcon?: boolean
accept?: string
required?: boolean
reserveMessageSpace?: boolean
}
const InputUploadForTest = InputUpload as DefineComponent<InputUploadProps>
@@ -240,4 +241,23 @@ describe('MalioInputUpload', () => {
await wrapper.get('input[type="text"]').trigger('click')
expect(clickSpy).not.toHaveBeenCalled()
})
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]')
})
})
+5 -1
View File
@@ -52,6 +52,7 @@
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
:id="`${inputId}-describedby`"
:class="[
hasError
@@ -59,7 +60,8 @@
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px] min-h-[1rem]',
'mt-1 text-xs ml-[2px]',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]"
>
{{ hint || error || success }}
@@ -92,6 +94,7 @@ const props = withDefaults(
displayIcon?: boolean
accept?: string
required?: boolean
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -108,6 +111,7 @@ const props = withDefaults(
displayIcon: true,
accept: '',
required: false,
reserveMessageSpace: true,
},
)