fix: readonly component style + TabList + required component (#61)
Release / release (push) Successful in 1m24s

| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

---------

Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-authored-by: matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #61
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #61.
This commit is contained in:
2026-06-04 06:45:24 +00:00
committed by Autin
parent b55050b2ad
commit 9ff3e83c03
58 changed files with 3192 additions and 167 deletions
+51
View File
@@ -24,6 +24,7 @@ type InputProps = {
iconPosition?: 'left' | 'right'
iconSize?: string | number
iconColor?: string
reserveMessageSpace?: boolean
}
const InputForTest = Input as DefineComponent<InputProps>
@@ -53,6 +54,16 @@ describe('MalioInputText', () => {
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'})
@@ -269,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'})
@@ -324,4 +354,25 @@ describe('MalioInputText', () => {
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')
})
})
@@ -24,6 +24,7 @@ type InputAmountProps = {
iconPosition?: 'left' | 'right'
iconSize?: string | number
iconColor?: string
reserveMessageSpace?: boolean
}
const InputAmountForTest = InputAmount as DefineComponent<InputAmountProps>
@@ -174,4 +175,59 @@ describe('MalioInputAmount', () => {
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mountInputAmount({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 = mountInputAmount({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
const wrapper = mountInputAmount({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 = mountInputAmount({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 vide : icône en text-m-muted', () => {
const wrapper = mountInputAmount({label: 'Champ', readonly: true})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
})
it('readonly rempli : label noir et icône noire', () => {
const wrapper = mountInputAmount({label: 'Champ', readonly: true, modelValue: '12.50'})
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]')
})
})
+28 -9
View File
@@ -30,7 +30,7 @@
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
{{ label }}<MalioRequiredMark v-if="required" />
</label>
<IconifyIcon
@@ -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 }}
@@ -63,6 +65,7 @@
import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioInputAmount', inheritAttrs: false})
@@ -88,6 +91,7 @@ const props = withDefaults(
iconPosition?: 'left' | 'right'
iconSize?: string | number
iconColor?: string
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -110,6 +114,7 @@ const props = withDefaults(
success: '',
iconSize: 20,
iconColor: 'text-m-muted',
reserveMessageSpace: true,
},
)
@@ -121,10 +126,15 @@ const isFocused = ref(false)
const inputId = computed(() => props.id?.toString() || `malio-input-amount-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success)
const isFilled = computed(() => currentValue.value.trim().length > 0)
const isReadonly = computed(() => props.readonly && !props.disabled)
const shouldFloatLabel = computed(() =>
isReadonly.value
? isFilled.value
: isFocused.value || currentValue.value.length > 0,
)
const mergedGroupClass = computed(() =>
twMerge(
@@ -134,31 +144,39 @@ const mergedGroupClass = computed(() =>
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
isFilled.value ? 'border-black' : 'border-m-muted',
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
isReadonly.value ? '' : 'grow-height',
isReadonly.value
? 'border-black'
: isFilled.value ? 'border-black' : 'border-m-muted',
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
hasError.value
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
: hasSuccess.value
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
: 'focus:border-m-primary',
: isReadonly.value ? '' : 'focus:border-m-primary',
isReadonly.value ? 'cursor-default' : '',
props.inputClass,
iconInputPaddingClass.value,
focusPaddingClass.value,
isReadonly.value ? '' : focusPaddingClass.value,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value,
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
shouldFloatLabel.value
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
: '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: disabled.value
? 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
@@ -234,6 +252,7 @@ const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (disabled.value) return props.iconColor
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isFocused.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return props.iconColor
@@ -36,6 +36,7 @@ type InputAutocompleteProps = {
noResultsText?: string
loadingText?: string
minSearchText?: string
reserveMessageSpace?: boolean
}
const InputAutocompleteForTest = InputAutocomplete as DefineComponent<InputAutocompleteProps>
@@ -65,6 +66,16 @@ describe('MalioInputAutocomplete', () => {
expect(wrapper.get('label').text()).toBe('Pays')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = 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', () => {
const wrapper = mountComponent({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('renders with type combobox role', () => {
const wrapper = mountComponent()
@@ -506,4 +517,50 @@ describe('MalioInputAutocomplete', () => {
expect(inputClasses).not.toContain('!border-b-0')
expect(inputClasses).toContain('!border-b-transparent')
})
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
const wrapper = mountComponent({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 = mountComponent({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 vide : chevron en text-m-muted', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
})
it('readonly rempli : label noir et icône noire', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true, modelValue: 'fr', options, iconName: 'mdi:magnify', iconPosition: 'left'})
expect(wrapper.get('label').classes()).toContain('text-black')
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]')
})
})
@@ -33,7 +33,7 @@
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
{{ label }}<MalioRequiredMark v-if="required" />
</label>
<IconifyIcon
@@ -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 }}
@@ -151,6 +153,7 @@
import {computed, onBeforeUnmount, onMounted, ref, useAttrs, useId, watch} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioInputAutocomplete', inheritAttrs: false})
@@ -187,6 +190,7 @@ const props = withDefaults(
noResultsText?: string
loadingText?: string
minSearchText?: string
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -215,6 +219,7 @@ const props = withDefaults(
noResultsText: 'Aucun résultat',
loadingText: 'Chargement…',
minSearchText: 'Tapez pour rechercher',
reserveMessageSpace: true,
},
)
@@ -248,7 +253,12 @@ const hasSelection = computed(() =>
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const isFilled = computed(() => inputValue.value.trim().length > 0 || hasSelection.value)
const shouldFloatLabel = computed(() => isFocused.value || inputValue.value.length > 0)
const isReadonly = computed(() => props.readonly && !props.disabled)
const shouldFloatLabel = computed(() =>
isReadonly.value
? isFilled.value
: isFocused.value || inputValue.value.length > 0,
)
const showMinSearch = computed(() =>
props.minSearchLength > 0 && inputValue.value.length < props.minSearchLength,
@@ -310,8 +320,11 @@ const labelPositionClass = computed(() =>
const mergedInputClass = computed(() =>
twMerge(
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
isFilled.value ? 'border-black' : 'border-m-muted',
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
isReadonly.value ? '' : 'grow-height',
isReadonly.value
? 'border-black'
: isFilled.value ? 'border-black' : 'border-m-muted',
props.disabled
? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted'
: 'cursor-text',
@@ -319,7 +332,8 @@ const mergedInputClass = computed(() =>
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
: hasSuccess.value
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
: 'focus:border-m-primary',
: isReadonly.value ? '' : 'focus:border-m-primary',
isReadonly.value ? 'cursor-default' : '',
isOpen.value ? '!rounded-b-none !border-b-transparent' : '',
props.inputClass,
iconInputPaddingClass.value,
@@ -337,7 +351,9 @@ const mergedLabelClass = computed(() =>
? 'text-m-success'
: props.disabled
? 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
@@ -346,6 +362,7 @@ const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (props.disabled) return props.iconColor
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isFocused.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return props.iconColor
@@ -354,6 +371,7 @@ const iconStateClass = computed(() => {
const chevronColorClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isOpen.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return 'text-m-muted'
@@ -23,6 +23,8 @@ type InputEmailProps = {
iconPosition?: 'left' | 'right'
iconSize?: string | number
iconColor?: string
lowercase?: boolean
reserveMessageSpace?: boolean
}
const InputEmailForTest = InputEmail as DefineComponent<InputEmailProps>
@@ -52,6 +54,16 @@ describe('MalioInputEmail', () => {
expect(wrapper.get('label').text()).toBe('Adresse email')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = 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', () => {
const wrapper = mountComponent({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('has type email', () => {
const wrapper = mountComponent()
@@ -225,4 +237,82 @@ describe('MalioInputEmail', () => {
expect(wrapper.get('input').attributes('autocomplete')).toBe('email')
})
it('supprime tous les espaces saisis', async () => {
const wrapper = mountComponent()
await wrapper.get('input').setValue(' a b @ c.com ')
const emits = wrapper.emitted('update:modelValue')!
expect(emits[emits.length - 1]).toEqual(['ab@c.com'])
expect(wrapper.get('input').element.value).toBe('ab@c.com')
})
it('conserve la casse par défaut', async () => {
const wrapper = mountComponent()
await wrapper.get('input').setValue('User@Example.COM')
const emits = wrapper.emitted('update:modelValue')!
expect(emits[emits.length - 1]).toEqual(['User@Example.COM'])
})
it('met en minuscules quand lowercase est vrai', async () => {
const wrapper = mountComponent({lowercase: true})
await wrapper.get('input').setValue('User@Example.COM')
const emits = wrapper.emitted('update:modelValue')!
expect(emits[emits.length - 1]).toEqual(['user@example.com'])
})
it('émet la valeur sanitisée en mode contrôlé', async () => {
const wrapper = mountComponent({modelValue: ''})
await wrapper.get('input').setValue(' a b @ c.com ')
expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual(['ab@c.com'])
})
it('resynchronise le DOM en mode contrôlé même quand la valeur sanitisée égale déjà modelValue', async () => {
// L'utilisateur ajoute un espace en fin alors que la valeur nettoyée vaut déjà modelValue.
// Le parent ne « changera » pas modelValue → Vue ne re-patche pas le DOM ; l'écriture
// manuelle target.value = sanitized est donc indispensable pour retirer l'espace affiché.
const wrapper = mountComponent({modelValue: 'ab@c.com'})
const input = wrapper.get('input')
await input.setValue('ab@c.com ')
expect(input.element.value).toBe('ab@c.com')
})
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
const wrapper = mountComponent({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 = mountComponent({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 = mountComponent({label: 'Champ', readonly: true, modelValue: 'user@example.com'})
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]')
})
})
+58 -12
View File
@@ -28,7 +28,7 @@
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
{{ label }}<MalioRequiredMark v-if="required" />
</label>
<IconifyIcon
@@ -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 }}
@@ -62,6 +64,7 @@
import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioInputEmail', inheritAttrs: false})
@@ -85,6 +88,8 @@ const props = withDefaults(
iconPosition?: 'left' | 'right'
iconSize?: string | number
iconColor?: string
lowercase?: boolean
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -105,6 +110,8 @@ const props = withDefaults(
success: '',
iconSize: 24,
iconColor: 'text-m-muted',
lowercase: false,
reserveMessageSpace: true,
},
)
@@ -116,10 +123,15 @@ const isFocused = ref(false)
const inputId = computed(() => props.id?.toString() || `malio-input-email-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success)
const isFilled = computed(() => currentValue.value.trim().length > 0)
const isReadonly = computed(() => props.readonly && !props.disabled)
const shouldFloatLabel = computed(() =>
isReadonly.value
? isFilled.value
: isFocused.value || currentValue.value.length > 0,
)
const mergedGroupClass = computed(() =>
twMerge(
'relative flex h-12 w-full items-center',
@@ -128,31 +140,39 @@ const mergedGroupClass = computed(() =>
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
isFilled.value ? 'border-black' : 'border-m-muted',
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
isReadonly.value ? '' : 'grow-height',
isReadonly.value
? 'border-black'
: isFilled.value ? 'border-black' : 'border-m-muted',
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
hasError.value
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
: hasSuccess.value
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
: 'focus:border-m-primary',
: isReadonly.value ? '' : 'focus:border-m-primary',
isReadonly.value ? 'cursor-default' : '',
props.inputClass,
iconInputPaddingClass.value,
focusPaddingClass.value,
isReadonly.value ? '' : focusPaddingClass.value,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value,
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
shouldFloatLabel.value
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
: '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: disabled.value
? 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
@@ -169,12 +189,37 @@ const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
}>()
const sanitizeEmail = (v: string) => {
let out = v.replace(/\s+/g, '')
if (props.lowercase) out = out.toLowerCase()
return out
}
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
if (!isControlled.value) {
localValue.value = target.value
const raw = target.value
const sanitized = sanitizeEmail(raw)
if (sanitized !== raw) {
// `<input type="email">` ne supporte pas l'API de sélection :
// selectionStart vaut null et setSelectionRange lève en navigateur.
// (En jsdom selectionStart peut renvoyer un nombre, d'où le code gardé ci-dessous.)
const caret = target.selectionStart
target.value = sanitized
if (caret !== null) {
const newCaret = sanitizeEmail(raw.slice(0, caret)).length
try {
target.setSelectionRange(newCaret, newCaret)
} catch {
/* type d'input sans support de sélection — ignore */
}
}
}
emit('update:modelValue', target.value)
if (!isControlled.value) {
localValue.value = sanitized
}
emit('update:modelValue', sanitized)
}
const iconInputPaddingClass = computed(() => {
@@ -203,6 +248,7 @@ const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (disabled.value) return props.iconColor
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isFocused.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return props.iconColor
@@ -6,9 +6,13 @@ import InputNumber from './InputNumber.vue'
type InputNumberProps = {
modelValue?: string | null
label?: string
required?: boolean
readonly?: boolean
min?: number | string
max?: number | string
error?: string
hint?: string
reserveMessageSpace?: boolean
}
const InputNumberForTest = InputNumber as DefineComponent<InputNumberProps>
@@ -162,4 +166,33 @@ describe('MalioInputNumber', () => {
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['5'])
expect(input.element.value).toBe('5')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mountInputNumber({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 = 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]')
})
})
+7 -2
View File
@@ -6,7 +6,7 @@
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
{{ label }}<MalioRequiredMark v-if="required" />
</label>
<button
type="button"
@@ -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 }}
@@ -70,6 +72,7 @@
import {computed, ref, useAttrs, useId} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioInputNumber', inheritAttrs: false})
@@ -90,6 +93,7 @@ const props = withDefaults(
hint?: string
error?: string
success?: string
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -107,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>
@@ -51,6 +52,16 @@ describe('MalioInputPassword', () => {
expect(wrapper.get('label').text()).toBe('Mot de passe')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = 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', () => {
const wrapper = mountComponent({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('has type password by default', () => {
const wrapper = mountComponent()
@@ -185,4 +196,55 @@ describe('MalioInputPassword', () => {
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
const wrapper = mountComponent({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 = mountComponent({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 vide : icône en text-m-muted', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
})
it('readonly rempli : label noir et icône noire', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true, modelValue: 'secret'})
expect(wrapper.get('label').classes()).toContain('text-black')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('readonly : eye toggle reste cliquable', async () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
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]')
})
})
+28 -9
View File
@@ -29,7 +29,7 @@
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
{{ label }}<MalioRequiredMark v-if="required" />
</label>
<IconifyIcon
@@ -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 }}
@@ -67,6 +69,7 @@
import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioInputPassword', inheritAttrs: false})
@@ -89,6 +92,7 @@ const props = withDefaults(
error?: string
success?: string
displayIcon?: boolean
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -108,6 +112,7 @@ const props = withDefaults(
error: '',
success: '',
displayIcon: true,
reserveMessageSpace: true,
},
)
@@ -124,10 +129,15 @@ const toggleVisibility = () => {
const inputId = computed(() => props.id?.toString() || `malio-input-password-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success)
const isFilled = computed(() => currentValue.value.trim().length > 0)
const isReadonly = computed(() => props.readonly && !props.disabled)
const shouldFloatLabel = computed(() =>
isReadonly.value
? isFilled.value
: isFocused.value || currentValue.value.length > 0,
)
const mergedGroupClass = computed(() =>
twMerge(
'relative flex h-12 w-full items-center',
@@ -136,16 +146,20 @@ const mergedGroupClass = computed(() =>
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
isFilled.value ? 'border-black' : 'border-m-muted',
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
isReadonly.value ? '' : 'grow-height',
isReadonly.value
? 'border-black'
: isFilled.value ? 'border-black' : 'border-m-muted',
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
hasError.value
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
: hasSuccess.value
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
: 'focus:border-m-primary',
: isReadonly.value ? '' : 'focus:border-m-primary',
isReadonly.value ? 'cursor-default' : '',
props.displayIcon ? '!pr-10' : '',
'focus:pl-[11px]',
isReadonly.value ? '' : 'focus:pl-[11px]',
props.inputClass,
),
)
@@ -153,14 +167,18 @@ const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
'left-3',
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
shouldFloatLabel.value
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
: '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: disabled.value
? 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
@@ -191,6 +209,7 @@ const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (disabled.value) return 'text-m-muted'
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isFocused.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return 'text-m-muted'
+65 -2
View File
@@ -27,6 +27,7 @@ type InputPhoneProps = {
addable?: boolean
addIconName?: string
addButtonLabel?: string
reserveMessageSpace?: boolean
}
const InputPhoneForTest = InputPhone as DefineComponent<InputPhoneProps>
@@ -56,6 +57,16 @@ describe('MalioInputPhone', () => {
expect(wrapper.get('label').text()).toBe('Téléphone')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = 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', () => {
const wrapper = mountComponent({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('has type tel', () => {
const wrapper = mountComponent()
@@ -264,10 +275,43 @@ describe('MalioInputPhone', () => {
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeDefined()
})
it('disables add button when readonly', () => {
it('add button is not natively disabled in readonly (onAdd guard blocks the action)', () => {
const wrapper = mountComponent({addable: true, readonly: true})
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeDefined()
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeUndefined()
})
it('readonly : border-black appliqué sur l\'input', () => {
const wrapper = mountComponent({label: 'Tel', readonly: true})
expect(wrapper.get('input').classes()).toContain('border-black')
})
it('readonly : icône en text-m-muted quand vide', () => {
const wrapper = mountComponent({label: 'Tel', readonly: true})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
})
it('readonly : icône en text-black quand rempli', () => {
const wrapper = mountComponent({label: 'Tel', readonly: true, modelValue: '+33612345678'})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('readonly : pas d\'apparence désactivée (pas opacity-40)', () => {
const wrapper = mountComponent({label: 'Tel', addable: true, readonly: true})
// opacity-40 was only ever applied to the add button, not the input
expect(wrapper.get('[data-test="add-button"]').classes()).not.toContain('opacity-40')
// and the input is not natively disabled in readonly:
expect(wrapper.get('input').attributes('disabled')).toBeUndefined()
})
it('readonly vide : label en text-m-muted', () => {
const wrapper = mountComponent({label: 'Tel', readonly: true})
expect(wrapper.get('label').classes()).toContain('text-m-muted')
})
it('readonly rempli : label en text-black', () => {
const wrapper = mountComponent({label: 'Tel', readonly: true, modelValue: '+33612345678'})
expect(wrapper.get('label').classes()).toContain('text-black')
})
it('renders the default add icon (mdi:plus)', () => {
@@ -340,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]')
})
})
+30 -11
View File
@@ -29,7 +29,7 @@
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
{{ label }}<MalioRequiredMark v-if="required" />
</label>
<IconifyIcon
@@ -44,7 +44,7 @@
<button
v-if="addable"
type="button"
:disabled="disabled || readonly"
:disabled="disabled"
:aria-label="addButtonLabel"
data-test="add-button"
:class="mergedAddButtonClass"
@@ -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 }}
@@ -82,6 +84,7 @@ import {vMaska} from 'maska/vue'
import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioInputPhone', inheritAttrs: false})
@@ -109,6 +112,7 @@ const props = withDefaults(
addable?: boolean
addIconName?: string
addButtonLabel?: string
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -133,6 +137,7 @@ const props = withDefaults(
addable: false,
addIconName: 'mdi:plus',
addButtonLabel: 'Ajouter un numéro',
reserveMessageSpace: true,
},
)
@@ -144,10 +149,15 @@ const isFocused = ref(false)
const inputId = computed(() => props.id?.toString() || `malio-input-phone-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success)
const isFilled = computed(() => currentValue.value.trim().length > 0)
const isReadonly = computed(() => props.readonly && !props.disabled)
const shouldFloatLabel = computed(() =>
isReadonly.value
? isFilled.value
: isFocused.value || currentValue.value.length > 0,
)
const mergedGroupClass = computed(() =>
twMerge(
'relative flex h-12 w-full items-center',
@@ -156,31 +166,39 @@ const mergedGroupClass = computed(() =>
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
isFilled.value ? 'border-black' : 'border-m-muted',
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
isReadonly.value ? '' : 'grow-height',
isReadonly.value
? 'border-black'
: isFilled.value ? 'border-black' : 'border-m-muted',
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
hasError.value
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
: hasSuccess.value
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
: 'focus:border-m-primary',
: isReadonly.value ? '' : 'focus:border-m-primary',
isReadonly.value ? 'cursor-default' : '',
props.inputClass,
iconInputPaddingClass.value,
focusPaddingClass.value,
isReadonly.value ? '' : focusPaddingClass.value,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value,
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
shouldFloatLabel.value
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
: '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: disabled.value
? 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
@@ -189,7 +207,7 @@ const mergedAddButtonClass = computed(() =>
twMerge(
'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer transition-opacity hover:opacity-70',
iconStateClass.value,
(props.disabled || props.readonly) ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '',
props.disabled ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '',
),
)
@@ -249,6 +267,7 @@ const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (disabled.value) return props.iconColor
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isFocused.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return props.iconColor
@@ -19,6 +19,8 @@ type InputRichTextProps = {
groupClass?: string
labelClass?: string
editorClass?: string
required?: boolean
reserveMessageSpace?: boolean
}
const InputRichTextForTest = InputRichText as DefineComponent<InputRichTextProps>
@@ -155,6 +157,18 @@ describe('MalioInputRichText', () => {
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.'})
@@ -162,4 +176,35 @@ describe('MalioInputRichText', () => {
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]')
})
})
+10 -2
View File
@@ -5,7 +5,7 @@
:for="editorId"
:class="mergedLabelClass"
>
{{ label }}
{{ label }}<MalioRequiredMark v-if="required" />
</label>
<!-- Mode lecture seule (rendu uniquement) -->
@@ -22,6 +22,7 @@
v-else
:id="editorId"
:class="mergedEditorWrapperClass"
:aria-required="required || undefined"
@click="focusEditor"
>
<div
@@ -184,6 +185,7 @@
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
:id="`${editorId}-describedby`"
:class="[
hasError
@@ -191,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 }}
@@ -210,6 +213,7 @@ import Color from '@tiptap/extension-color'
import Highlight from '@tiptap/extension-highlight'
import { Markdown } from 'tiptap-markdown'
import { twMerge } from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({ name: 'MalioInputRichText', inheritAttrs: false })
@@ -232,6 +236,8 @@ const props = withDefaults(
groupClass?: string
labelClass?: string
editorClass?: string
required?: boolean
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -249,6 +255,8 @@ const props = withDefaults(
groupClass: '',
labelClass: '',
editorClass: '',
required: false,
reserveMessageSpace: true,
},
)
+28 -9
View File
@@ -30,7 +30,7 @@
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
{{ label }}<MalioRequiredMark v-if="required" />
</label>
<IconifyIcon
@@ -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 }}
@@ -66,6 +68,7 @@ import {vMaska} from 'maska/vue'
import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioInputText', inheritAttrs: false})
@@ -93,6 +96,7 @@ const props = withDefaults(
iconSize?: string | number
iconColor?: string
mask?: string | MaskInputOptions
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -116,6 +120,7 @@ const props = withDefaults(
iconSize: 24,
iconColor: 'text-m-muted',
mask: undefined,
reserveMessageSpace: true,
},
)
@@ -127,10 +132,15 @@ const isFocused = ref(false)
const inputId = computed(() => props.id?.toString() || `malio-input-text-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success)
const isFilled = computed(() => currentValue.value.trim().length > 0)
const isReadonly = computed(() => props.readonly && !props.disabled)
const shouldFloatLabel = computed(() =>
isReadonly.value
? isFilled.value
: isFocused.value || currentValue.value.length > 0,
)
const mergedGroupClass = computed(() =>
twMerge(
'relative flex h-12 w-full items-center',
@@ -139,31 +149,39 @@ const mergedGroupClass = computed(() =>
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
isFilled.value ? 'border-black' : 'border-m-muted',
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
isReadonly.value ? '' : 'grow-height',
isReadonly.value
? 'border-black'
: isFilled.value ? 'border-black' : 'border-m-muted',
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
hasError.value
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
: hasSuccess.value
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
: 'focus:border-m-primary',
: isReadonly.value ? '' : 'focus:border-m-primary',
isReadonly.value ? 'cursor-default' : '',
props.inputClass,
iconInputPaddingClass.value,
focusPaddingClass.value,
isReadonly.value ? '' : focusPaddingClass.value,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value,
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
shouldFloatLabel.value
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
: '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: disabled.value
? 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
@@ -214,6 +232,7 @@ const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (disabled.value) return props.iconColor
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isFocused.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return props.iconColor
@@ -21,6 +21,7 @@ type InputTextAreaProps = {
error?: string
success?: string
rounded?: string
reserveMessageSpace?: boolean
}
const InputTextAreaForTest = InputTextArea as DefineComponent<InputTextAreaProps>
@@ -183,4 +184,53 @@ describe('MalioInputTextArea', () => {
expect(wrapper.get('textarea').classes()).not.toContain('textarea-scrollbar-primary')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mount(InputTextAreaForTest, {props: {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(InputTextAreaForTest, {props: {label: 'Champ'}})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('readonly : bordure noire même vide, pas de bleu', () => {
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', readonly: true}})
const field = wrapper.get('textarea')
expect(field.classes()).toContain('border-black')
expect(field.classes()).not.toContain('border-m-muted')
expect(field.classes()).not.toContain('focus:border-m-primary')
})
it('readonly vide : label gris, pas de bleu focus', () => {
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', readonly: true}})
expect(wrapper.get('label').classes()).toContain('text-m-muted')
// En readonly, pas de couleur primary sur le label
expect(wrapper.get('label').classes()).not.toContain('text-m-primary')
})
it('readonly rempli : label noir', () => {
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]')
})
})
+25 -10
View File
@@ -8,14 +8,14 @@
:autocomplete="autocomplete"
class="floating-input peer w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent overflow-auto"
:class="[
isFilled ? 'border-black' : 'border-m-muted',
disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : 'cursor-text',
isReadonly ? 'border-black' : (isFilled ? 'border-black' : 'border-m-muted'),
disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : (isReadonly ? 'cursor-default' : 'cursor-text'),
hasError
? 'border-m-danger focus:border-m-danger'
: hasSuccess
? 'border-m-success focus:border-m-success'
: 'focus:border-m-primary',
isFocused ? 'textarea-scrollbar-primary' : '',
: isReadonly ? '' : 'focus:border-m-primary',
isReadonly ? '' : (isFocused ? 'textarea-scrollbar-primary' : ''),
textInput,
showCounterComputed ? 'pb-6' : '',
rounded,
@@ -47,11 +47,13 @@
? 'text-m-success'
: disabled
? 'text-m-muted'
: isFocused ? 'text-m-primary' : shouldFloatLabel ? 'text-black' : 'text-m-muted',
: isReadonly
? (isFilled ? 'text-black' : 'text-m-muted')
: (isFocused ? 'text-m-primary' : shouldFloatLabel ? 'text-black' : 'text-m-muted'),
textLabel,
]"
>
{{ label }}
{{ label }}<MalioRequiredMark v-if="required" />
</label>
<span
v-if="showCounterComputed"
@@ -61,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`"
@@ -83,6 +88,7 @@
<script setup lang="ts">
import {computed, ref, useAttrs, useId} from 'vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioInputTextArea', inheritAttrs: false})
@@ -111,6 +117,7 @@ const props = withDefaults(
success?: string
rounded?: string
groupClass?: string
reserveMessageSpace?: boolean
}>(),
{
@@ -137,11 +144,14 @@ const props = withDefaults(
minResizeHeight: 40,
maxResizeHeight: 320,
groupClass: '',
reserveMessageSpace: true,
},
)
const mergedGroupClass = computed(() =>
twMerge('flex flex-col w-full', props.groupClass),
// pt-1 (4px) aligne le haut de la textarea avec les inputs floating-label,
// qui centrent un champ de 40px dans un groupe h-12 (≈ 4px de décalage en haut).
twMerge('flex flex-col w-full pt-1', props.groupClass),
)
const attrs = useAttrs()
@@ -152,9 +162,15 @@ const isFocused = ref(false)
const inputId = computed(() => props.id?.toString() || `malio-input-textarea-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const isFilled = computed(() => currentValue.value.trim().length > 0)
const isReadonly = computed(() => props.readonly && !props.disabled)
const shouldFloatLabel = computed(() =>
isReadonly.value
? isFilled.value
: isFocused.value || currentValue.value.length > 0,
)
const rowsCount = computed(() => Math.max(1, Number(props.size || 3)))
const currentLength = computed(() => (currentValue.value ?? '').length)
const showCounterComputed = computed(() =>
@@ -168,7 +184,6 @@ const textareaStyle = computed(() => ({
minHeight: toCssSize(props.minResizeHeight),
maxHeight: toCssSize(props.maxResizeHeight),
}))
const isFilled = computed(() => currentValue.value.trim().length > 0)
const describedBy = computed(() =>
(hasError.value || hasSuccess.value || !!props.hint) ? `${inputId.value}-describedby` : undefined,
)
+75 -1
View File
@@ -1,4 +1,4 @@
import {describe, expect, it} from 'vitest'
import {describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
@@ -12,11 +12,14 @@ type InputUploadProps = {
labelClass?: string
groupClass?: string
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
displayIcon?: boolean
accept?: string
required?: boolean
reserveMessageSpace?: boolean
}
const InputUploadForTest = InputUpload as DefineComponent<InputUploadProps>
@@ -167,6 +170,11 @@ describe('MalioInputUpload', () => {
expect(wrapper.get('input[type="text"]').attributes('aria-invalid')).toBe('false')
})
it('expose aria-required sur le champ visible quand required est vrai', () => {
const wrapper = mountComponent({label: 'Champ', required: true})
expect(wrapper.get('input[type="text"]').attributes('aria-required')).toBe('true')
})
it('passes accept attribute to file input', () => {
const wrapper = mountComponent({accept: '.pdf,.doc'})
@@ -186,4 +194,70 @@ describe('MalioInputUpload', () => {
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = 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', () => {
const wrapper = mountComponent({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
const field = wrapper.get('input[type="text"]')
expect(field.classes()).toContain('border-black')
expect(field.classes()).not.toContain('border-m-muted')
expect(field.classes()).not.toContain('grow-height')
expect(field.classes()).not.toContain('focus:border-m-primary')
})
it('readonly vide : label gris, pas de bleu', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
const label = wrapper.get('label')
expect(label.classes()).not.toContain('peer-focus:text-m-primary')
expect(label.classes()).toContain('text-m-muted')
})
it('readonly vide : icône en text-m-muted', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
})
it('readonly rempli : label noir + icône noire', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true, modelValue: 'fichier.pdf'})
expect(wrapper.get('label').classes()).toContain('text-black')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('readonly empêche l\'ouverture du sélecteur de fichier', async () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
const fileInput = wrapper.get('input[type="file"]').element as HTMLInputElement
const clickSpy = vi.spyOn(fileInput, 'click')
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]')
})
})
+38 -13
View File
@@ -9,6 +9,7 @@
:accept="accept"
class="hidden"
:disabled="disabled"
:required="required"
@change="onFileChange"
>
@@ -19,6 +20,7 @@
:value="currentDisplayValue"
:readonly="true"
:aria-invalid="!!error"
:aria-required="required || undefined"
:aria-describedby="describedBy"
v-bind="attrs"
placeholder="_"
@@ -33,7 +35,7 @@
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
{{ label }}<MalioRequiredMark v-if="required" />
</label>
<IconifyIcon
@@ -50,6 +52,7 @@
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
:id="`${inputId}-describedby`"
:class="[
hasError
@@ -57,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 }}
@@ -70,6 +74,7 @@
import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioInputUpload', inheritAttrs: false})
@@ -82,11 +87,14 @@ const props = withDefaults(
labelClass?: string
groupClass?: string
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
displayIcon?: boolean
accept?: string
required?: boolean
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -96,11 +104,14 @@ const props = withDefaults(
labelClass: '',
groupClass: '',
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
displayIcon: true,
accept: '',
required: false,
reserveMessageSpace: true,
},
)
@@ -113,10 +124,16 @@ const fileInputRef = ref<HTMLInputElement | null>(null)
const inputId = computed(() => props.id?.toString() || `malio-input-upload-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const currentDisplayValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
const shouldFloatLabel = computed(() => isFocused.value || currentDisplayValue.value.length > 0)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success)
const isFilled = computed(() => currentDisplayValue.value.trim().length > 0)
const disabled = computed(() => props.disabled)
const isReadonly = computed(() => props.readonly && !props.disabled)
const shouldFloatLabel = computed(() =>
isReadonly.value
? isFilled.value
: isFocused.value || currentDisplayValue.value.length > 0,
)
const mergedGroupClass = computed(() =>
twMerge(
'relative flex h-12 w-full items-center',
@@ -125,16 +142,21 @@ const mergedGroupClass = computed(() =>
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
isFilled.value ? 'border-black' : 'border-m-muted',
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-pointer',
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md cursor-pointer',
isReadonly.value ? '' : 'grow-height',
isReadonly.value
? 'border-black'
: isFilled.value ? 'border-black' : 'border-m-muted',
disabled.value ? 'text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : '',
hasError.value
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
: hasSuccess.value
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
: 'focus:border-m-primary',
: isReadonly.value ? '' : 'focus:border-m-primary',
props.displayIcon ? '!pr-10' : '',
'focus:pl-[11px]',
isReadonly.value ? '' : 'focus:pl-[11px]',
isReadonly.value ? 'cursor-default' : '',
disabled.value ? 'cursor-not-allowed' : '',
props.inputClass,
),
)
@@ -142,14 +164,18 @@ const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
'left-3',
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
shouldFloatLabel.value
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
: '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: disabled.value
? 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
@@ -168,7 +194,7 @@ const emit = defineEmits<{
}>()
const openFilePicker = () => {
if (props.disabled) return
if (props.disabled || props.readonly) return
fileInputRef.value?.click()
}
@@ -185,12 +211,11 @@ const onFileChange = (event: Event) => {
}
}
const disabled = computed(() => props.disabled)
const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (disabled.value) return 'text-m-muted'
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isFocused.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return 'text-m-muted'