diff --git a/app/components/malio/input/Input.test.ts b/app/components/malio/input/Input.test.ts index 82d9c7d..8be144e 100644 --- a/app/components/malio/input/Input.test.ts +++ b/app/components/malio/input/Input.test.ts @@ -334,4 +334,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') + }) }) diff --git a/app/components/malio/input/InputAmount.test.ts b/app/components/malio/input/InputAmount.test.ts index 1efcba5..8085599 100644 --- a/app/components/malio/input/InputAmount.test.ts +++ b/app/components/malio/input/InputAmount.test.ts @@ -184,4 +184,25 @@ describe('MalioInputAmount', () => { 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 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') + }) }) diff --git a/app/components/malio/input/InputAmount.vue b/app/components/malio/input/InputAmount.vue index 4d2c1e7..dbb7302 100644 --- a/app/components/malio/input/InputAmount.vue +++ b/app/components/malio/input/InputAmount.vue @@ -122,10 +122,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( @@ -135,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, ), ) @@ -235,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 diff --git a/app/components/malio/input/InputAutocomplete.test.ts b/app/components/malio/input/InputAutocomplete.test.ts index ff397dc..9b17a97 100644 --- a/app/components/malio/input/InputAutocomplete.test.ts +++ b/app/components/malio/input/InputAutocomplete.test.ts @@ -516,4 +516,25 @@ 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 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') + }) }) diff --git a/app/components/malio/input/InputAutocomplete.vue b/app/components/malio/input/InputAutocomplete.vue index b9f904b..7c40cf1 100644 --- a/app/components/malio/input/InputAutocomplete.vue +++ b/app/components/malio/input/InputAutocomplete.vue @@ -249,7 +249,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, @@ -311,8 +316,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', @@ -320,7 +328,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, @@ -338,7 +347,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, ), ) @@ -347,6 +358,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 @@ -355,6 +367,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' diff --git a/app/components/malio/input/InputEmail.test.ts b/app/components/malio/input/InputEmail.test.ts index 96f01f9..ee0cfc3 100644 --- a/app/components/malio/input/InputEmail.test.ts +++ b/app/components/malio/input/InputEmail.test.ts @@ -274,4 +274,25 @@ describe('MalioInputEmail', () => { 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') + }) }) diff --git a/app/components/malio/input/InputEmail.vue b/app/components/malio/input/InputEmail.vue index 6586996..622ffad 100644 --- a/app/components/malio/input/InputEmail.vue +++ b/app/components/malio/input/InputEmail.vue @@ -119,10 +119,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', @@ -131,31 +136,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, ), ) @@ -231,6 +244,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 diff --git a/app/components/malio/input/InputPassword.test.ts b/app/components/malio/input/InputPassword.test.ts index 4019afa..dac4f20 100644 --- a/app/components/malio/input/InputPassword.test.ts +++ b/app/components/malio/input/InputPassword.test.ts @@ -195,4 +195,31 @@ 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 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') + }) }) diff --git a/app/components/malio/input/InputPassword.vue b/app/components/malio/input/InputPassword.vue index 0c66248..8cb018a 100644 --- a/app/components/malio/input/InputPassword.vue +++ b/app/components/malio/input/InputPassword.vue @@ -125,10 +125,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', @@ -137,16 +142,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, ), ) @@ -154,14 +163,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, ), ) @@ -192,6 +205,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' diff --git a/app/components/malio/input/InputText.vue b/app/components/malio/input/InputText.vue index 066f694..bdd2992 100644 --- a/app/components/malio/input/InputText.vue +++ b/app/components/malio/input/InputText.vue @@ -128,10 +128,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', @@ -140,31 +145,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, ), ) @@ -215,6 +228,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 diff --git a/app/components/malio/input/InputTextArea.test.ts b/app/components/malio/input/InputTextArea.test.ts index a3767b7..97e24e4 100644 --- a/app/components/malio/input/InputTextArea.test.ts +++ b/app/components/malio/input/InputTextArea.test.ts @@ -193,4 +193,24 @@ describe('MalioInputTextArea', () => { 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') + }) }) diff --git a/app/components/malio/input/InputTextArea.vue b/app/components/malio/input/InputTextArea.vue index 2e09f13..018f2d1 100644 --- a/app/components/malio/input/InputTextArea.vue +++ b/app/components/malio/input/InputTextArea.vue @@ -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', + isReadonly ? 'border-black' : (isFilled ? 'border-black' : 'border-m-muted'), disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : '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,7 +47,9 @@ ? '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, ]" > @@ -153,9 +155,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(() => @@ -169,7 +177,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, )