diff --git a/app/components/malio/input/InputUpload.test.ts b/app/components/malio/input/InputUpload.test.ts index a74bbec..d6239b8 100644 --- a/app/components/malio/input/InputUpload.test.ts +++ b/app/components/malio/input/InputUpload.test.ts @@ -12,6 +12,7 @@ type InputUploadProps = { labelClass?: string groupClass?: string disabled?: boolean + readonly?: boolean hint?: string error?: string success?: string @@ -204,4 +205,34 @@ describe('MalioInputUpload', () => { 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 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}) + // openFilePicker doit être un no-op : cliquer le champ ne déclenche pas l'input file caché. + // Vérifie au minimum que le champ visible reste readonly et qu'aucune erreur n'est levée. + await wrapper.get('input[type="text"]').trigger('click') + expect(wrapper.get('input[type="text"]').attributes('readonly')).toBeDefined() + }) }) diff --git a/app/components/malio/input/InputUpload.vue b/app/components/malio/input/InputUpload.vue index 094f2dd..90b0af8 100644 --- a/app/components/malio/input/InputUpload.vue +++ b/app/components/malio/input/InputUpload.vue @@ -85,6 +85,7 @@ const props = withDefaults( labelClass?: string groupClass?: string disabled?: boolean + readonly?: boolean hint?: string error?: string success?: string @@ -100,6 +101,7 @@ const props = withDefaults( labelClass: '', groupClass: '', disabled: false, + readonly: false, hint: '', error: '', success: '', @@ -118,10 +120,16 @@ const fileInputRef = ref(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', @@ -130,16 +138,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', + isReadonly.value + ? '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' + : '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', + isReadonly.value + ? 'border-black cursor-default' + : isFilled.value ? 'border-black' : 'border-m-muted', + disabled.value ? 'cursor-not-allowed 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 && !disabled.value ? 'cursor-pointer' : '', props.inputClass, ), ) @@ -154,7 +167,9 @@ const mergedLabelClass = computed(() => ? '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, ), ) @@ -173,7 +188,7 @@ const emit = defineEmits<{ }>() const openFilePicker = () => { - if (props.disabled) return + if (props.disabled || props.readonly) return fileInputRef.value?.click() } @@ -190,12 +205,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'