feat(ui) : prop reserveMessageSpace (défaut true) sur Select/SelectCheckbox/Checkbox/date/time

Ajoute reserveMessageSpace (défaut true) pour permettre de ne pas réserver
la ligne de message d'aide quand aucun message n'est présent. Comportement
inchangé par défaut. La famille date hérite via $attrs → CalendarField.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 16:53:22 +02:00
parent cda0f994ca
commit 1b5a4c9920
12 changed files with 152 additions and 6 deletions
@@ -17,6 +17,7 @@ type CheckboxProps = {
hint?: string hint?: string
error?: string error?: string
success?: string success?: string
reserveMessageSpace?: boolean
} }
const CheckboxForTest = Checkbox as DefineComponent<CheckboxProps> const CheckboxForTest = Checkbox as DefineComponent<CheckboxProps>
@@ -171,4 +172,23 @@ describe('MalioCheckbox', () => {
const wrapper = mountCheckbox({label: 'Champ'}) const wrapper = mountCheckbox({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false) expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
}) })
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountCheckbox({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 = mountCheckbox({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mountCheckbox({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
@@ -30,6 +30,7 @@
</label> </label>
<p <p
v-if="reserveMessageSpace || hint || error || success"
:id="`${inputId}-describedby`" :id="`${inputId}-describedby`"
:class="mergedMessageClass" :class="mergedMessageClass"
> >
@@ -60,6 +61,7 @@ const props = withDefaults(
hint?: string hint?: string
error?: string error?: string
success?: string success?: string
reserveMessageSpace?: boolean
}>(), }>(),
{ {
id: '', id: '',
@@ -75,6 +77,7 @@ const props = withDefaults(
hint: '', hint: '',
error: '', error: '',
success: '', success: '',
reserveMessageSpace: true,
}, },
) )
@@ -121,7 +124,8 @@ const mergedLabelClass = computed(() =>
const mergedMessageClass = computed(() => const mergedMessageClass = computed(() =>
twMerge( twMerge(
'text-xs min-h-[1rem]', 'text-xs',
props.reserveMessageSpace ? 'min-h-[1rem]' : '',
hasError.value hasError.value
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess.value : hasSuccess.value
+22
View File
@@ -21,6 +21,7 @@ type DateProps = {
inputClass?: string inputClass?: string
labelClass?: string labelClass?: string
groupClass?: string groupClass?: string
reserveMessageSpace?: boolean
} }
const DateForTest = Date_ as DefineComponent<DateProps> const DateForTest = Date_ as DefineComponent<DateProps>
@@ -236,4 +237,25 @@ describe('MalioDate', () => {
expect(input.value).toBe('25/12/2026') expect(input.value).toBe('25/12/2026')
}) })
}) })
describe('reserveMessageSpace', () => {
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountDate({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 = mountDate({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mountDate({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]')
})
})
}) })
@@ -85,10 +85,12 @@
</div> </div>
<p <p
v-if="reserveMessageSpace || hint || error || success"
:id="`${inputId}-describedby`" :id="`${inputId}-describedby`"
:class="[ :class="[
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted', 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]' : '',
]" ]"
> >
{{ error || success || hint }} {{ error || success || hint }}
@@ -126,6 +128,7 @@ const props = withDefaults(
inputClass?: string inputClass?: string
labelClass?: string labelClass?: string
groupClass?: string groupClass?: string
reserveMessageSpace?: boolean
}>(), }>(),
{ {
id: '', id: '',
@@ -142,6 +145,7 @@ const props = withDefaults(
inputClass: '', inputClass: '',
labelClass: '', labelClass: '',
groupClass: '', groupClass: '',
reserveMessageSpace: true,
}, },
) )
@@ -23,6 +23,7 @@ type SelectProps = {
disabled?: boolean disabled?: boolean
readonly?: boolean readonly?: boolean
required?: boolean required?: boolean
reserveMessageSpace?: boolean
} }
const SelectForTest = Select as DefineComponent<SelectProps> const SelectForTest = Select as DefineComponent<SelectProps>
@@ -359,4 +360,23 @@ describe('MalioSelect', () => {
expect(trigger.attributes('aria-readonly')).toBeUndefined() expect(trigger.attributes('aria-readonly')).toBeUndefined()
expect(trigger.attributes('disabled')).toBeDefined() expect(trigger.attributes('disabled')).toBeDefined()
}) })
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mount(SelectForTest, {props: {modelValue: null, 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(SelectForTest, {props: {modelValue: null, 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(SelectForTest, {props: {modelValue: null, 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]')
})
}) })
+5 -1
View File
@@ -166,6 +166,7 @@
</ul> </ul>
</div> </div>
<p <p
v-if="reserveMessageSpace || hint || error || success"
:id="`${buttonId}-describedby`" :id="`${buttonId}-describedby`"
:class="[ :class="[
hasError hasError
@@ -173,7 +174,8 @@
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: 'text-m-muted', : 'text-m-muted',
'mt-1 ml-[2px] text-xs min-h-[1rem]', 'mt-1 ml-[2px] text-xs',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]" ]"
> >
{{ error || success || hint }} {{ error || success || hint }}
@@ -210,6 +212,7 @@ const props = withDefaults(defineProps<{
groupClass?: string groupClass?: string
noOptionsText?: string noOptionsText?: string
required?: boolean required?: boolean
reserveMessageSpace?: boolean
}>(), { }>(), {
options: () => [], options: () => [],
emptyOptionLabel: '', emptyOptionLabel: '',
@@ -226,6 +229,7 @@ const props = withDefaults(defineProps<{
groupClass: '', groupClass: '',
noOptionsText: 'Aucune option disponible', noOptionsText: 'Aucune option disponible',
required: false, required: false,
reserveMessageSpace: true,
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -27,6 +27,7 @@ type SelectCheckboxProps = {
readonly?: boolean readonly?: boolean
groupClass?: string groupClass?: string
required?: boolean required?: boolean
reserveMessageSpace?: boolean
} }
const SelectCheckboxForTest = SelectCheckbox as DefineComponent<SelectCheckboxProps> const SelectCheckboxForTest = SelectCheckbox as DefineComponent<SelectCheckboxProps>
@@ -346,4 +347,23 @@ describe('MalioSelectCheckbox', () => {
expect(trigger.attributes('aria-readonly')).toBeUndefined() expect(trigger.attributes('aria-readonly')).toBeUndefined()
expect(trigger.attributes('disabled')).toBeDefined() expect(trigger.attributes('disabled')).toBeDefined()
}) })
it('réserve lespace 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]')
})
}) })
@@ -215,6 +215,7 @@
</ul> </ul>
</div> </div>
<p <p
v-if="reserveMessageSpace || hint || error || success"
:id="`${buttonId}-describedby`" :id="`${buttonId}-describedby`"
:class="[ :class="[
hasError hasError
@@ -222,7 +223,8 @@
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: 'text-m-muted', : 'text-m-muted',
'mt-1 ml-[2px] text-xs min-h-[1rem]', 'mt-1 ml-[2px] text-xs',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]" ]"
> >
{{ error || success || hint }} {{ error || success || hint }}
@@ -263,6 +265,7 @@ const props = withDefaults(defineProps<{
groupClass?: string groupClass?: string
noOptionsText?: string noOptionsText?: string
required?: boolean required?: boolean
reserveMessageSpace?: boolean
}>(), { }>(), {
modelValue: () => [], modelValue: () => [],
options: () => [], options: () => [],
@@ -283,6 +286,7 @@ const props = withDefaults(defineProps<{
groupClass: '', groupClass: '',
noOptionsText: 'Aucune option disponible', noOptionsText: 'Aucune option disponible',
required: false, required: false,
reserveMessageSpace: true,
}) })
const emit = defineEmits<{ const emit = defineEmits<{
+20
View File
@@ -17,6 +17,7 @@ type TimeProps = {
hint?: string hint?: string
error?: string error?: string
success?: string success?: string
reserveMessageSpace?: boolean
} }
const TimeForTest = Time as DefineComponent<TimeProps> const TimeForTest = Time as DefineComponent<TimeProps>
@@ -86,4 +87,23 @@ describe('MalioTime', () => {
const wrapper = mountTime({label: 'Champ'}) const wrapper = mountTime({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false) expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
}) })
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountTime({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 = mountTime({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mountTime({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
@@ -58,6 +58,7 @@
</div> </div>
<p <p
v-if="reserveMessageSpace || hint || error || success"
:id="`${inputId}-describedby`" :id="`${inputId}-describedby`"
:class="[ :class="[
hasError hasError
@@ -65,7 +66,8 @@
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: 'text-m-muted', : 'text-m-muted',
'mt-1 ml-[2px] text-xs min-h-[1rem]', 'mt-1 ml-[2px] text-xs',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]" ]"
> >
{{ error || success || hint }} {{ error || success || hint }}
@@ -95,6 +97,7 @@ const props = withDefaults(
hint?: string hint?: string
error?: string error?: string
success?: string success?: string
reserveMessageSpace?: boolean
}>(), }>(),
{ {
id: '', id: '',
@@ -110,6 +113,7 @@ const props = withDefaults(
hint: '', hint: '',
error: '', error: '',
success: '', success: '',
reserveMessageSpace: true,
}, },
) )
@@ -19,6 +19,7 @@ type TimePickerProps = {
inputClass?: string inputClass?: string
labelClass?: string labelClass?: string
groupClass?: string groupClass?: string
reserveMessageSpace?: boolean
} }
const TimePickerForTest = TimePicker as DefineComponent<TimePickerProps> const TimePickerForTest = TimePicker as DefineComponent<TimePickerProps>
@@ -120,4 +121,23 @@ describe('MalioTimePicker', () => {
expect(label.classes()).toContain('text-black') expect(label.classes()).toContain('text-black')
expect(icon.classes()).toContain('text-black') expect(icon.classes()).toContain('text-black')
}) })
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountPicker({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 = mountPicker({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mountPicker({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
@@ -78,10 +78,12 @@
</div> </div>
<p <p
v-if="reserveMessageSpace || hint || error || success"
:id="`${inputId}-describedby`" :id="`${inputId}-describedby`"
:class="[ :class="[
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted', 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]' : '',
]" ]"
> >
{{ error || success || hint }} {{ error || success || hint }}
@@ -116,6 +118,7 @@ const props = withDefaults(
inputClass?: string inputClass?: string
labelClass?: string labelClass?: string
groupClass?: string groupClass?: string
reserveMessageSpace?: boolean
}>(), }>(),
{ {
id: '', id: '',
@@ -134,6 +137,7 @@ const props = withDefaults(
inputClass: '', inputClass: '',
labelClass: '', labelClass: '',
groupClass: '', groupClass: '',
reserveMessageSpace: true,
}, },
) )