feat : composant upload
This commit is contained in:
88
.playground/pages/composant/inputUpload.vue
Normal file
88
.playground/pages/composant/inputUpload.vue
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Simple</h2>
|
||||||
|
<MalioInputUpload label="Fichier" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec label et v-model</h2>
|
||||||
|
<MalioInputUpload
|
||||||
|
v-model="uploadValue"
|
||||||
|
label="Téléverser un document"
|
||||||
|
/>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">Valeur : {{ uploadValue || '(aucun)' }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec accept (PDF)</h2>
|
||||||
|
<MalioInputUpload
|
||||||
|
label="Document PDF"
|
||||||
|
accept=".pdf"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
||||||
|
<MalioInputUpload
|
||||||
|
model-value="document.pdf"
|
||||||
|
disabled
|
||||||
|
label="Fichier"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
||||||
|
<MalioInputUpload
|
||||||
|
label="Fichier"
|
||||||
|
hint="Formats acceptés : PDF, DOC, DOCX"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||||
|
<MalioInputUpload
|
||||||
|
model-value="image.bmp"
|
||||||
|
label="Fichier"
|
||||||
|
error="Format non supporté"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Succès</h2>
|
||||||
|
<MalioInputUpload
|
||||||
|
model-value="rapport.pdf"
|
||||||
|
label="Fichier"
|
||||||
|
success="Fichier valide"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Validation dynamique</h2>
|
||||||
|
<MalioInputUpload
|
||||||
|
v-model="dynamicUpload"
|
||||||
|
label="Document PDF"
|
||||||
|
accept=".pdf"
|
||||||
|
hint="Seuls les fichiers PDF sont acceptés"
|
||||||
|
:error="dynamicError"
|
||||||
|
:success="dynamicSuccess"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const uploadValue = ref('')
|
||||||
|
const dynamicUpload = ref('')
|
||||||
|
|
||||||
|
const dynamicError = computed(() => {
|
||||||
|
if (!dynamicUpload.value) return ''
|
||||||
|
return dynamicUpload.value.endsWith('.pdf') ? '' : 'Seuls les fichiers PDF sont acceptés'
|
||||||
|
})
|
||||||
|
const dynamicSuccess = computed(() => {
|
||||||
|
if (!dynamicUpload.value) return ''
|
||||||
|
return dynamicUpload.value.endsWith('.pdf') ? 'Fichier PDF valide' : ''
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -16,6 +16,7 @@ Liste des évolutions de la librairie Malio layer UI
|
|||||||
* [#407] Création d'un composant time
|
* [#407] Création d'un composant time
|
||||||
* Création d'un composant textarea
|
* Création d'un composant textarea
|
||||||
* [#MUI-8] Création d'un composant mot de passe
|
* [#MUI-8] Création d'un composant mot de passe
|
||||||
|
* [#MUI-9] Création d'un composant upload
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
175
app/components/malio/InputUpload.test.ts
Normal file
175
app/components/malio/InputUpload.test.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import {describe, expect, it} from 'vitest'
|
||||||
|
import {mount} from '@vue/test-utils'
|
||||||
|
import type {DefineComponent} from 'vue'
|
||||||
|
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||||
|
import InputUpload from './InputUpload.vue'
|
||||||
|
|
||||||
|
type InputUploadProps = {
|
||||||
|
id?: string
|
||||||
|
label?: string
|
||||||
|
modelValue?: string | null
|
||||||
|
inputClass?: string
|
||||||
|
labelClass?: string
|
||||||
|
groupClass?: string
|
||||||
|
disabled?: boolean
|
||||||
|
hint?: string
|
||||||
|
error?: string
|
||||||
|
success?: string
|
||||||
|
displayIcon?: boolean
|
||||||
|
accept?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const InputUploadForTest = InputUpload as DefineComponent<InputUploadProps>
|
||||||
|
|
||||||
|
const mountComponent = (props: InputUploadProps = {}) =>
|
||||||
|
mount(InputUploadForTest, {
|
||||||
|
props,
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
IconifyIcon: {
|
||||||
|
template: '<span data-test="icon" v-bind="$attrs" />',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('MalioInputUpload', () => {
|
||||||
|
it('renders the initial display value', () => {
|
||||||
|
const wrapper = mountComponent({modelValue: 'document.pdf'})
|
||||||
|
|
||||||
|
expect(wrapper.get('input[type="text"]').element.value).toBe('document.pdf')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the label text', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Téléverser un fichier'})
|
||||||
|
|
||||||
|
expect(wrapper.get('label').text()).toBe('Téléverser un fichier')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has a hidden file input', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
expect(wrapper.find('input[type="file"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('input[type="file"]').classes()).toContain('hidden')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('text input is readonly', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
expect(wrapper.get('input[type="text"]').attributes('readonly')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders icon by default', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="icon"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render icon when displayIcon is false', () => {
|
||||||
|
const wrapper = mountComponent({displayIcon: false})
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="icon"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the correct upload icon', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
const iconComponent = wrapper.findComponent(IconifyIcon)
|
||||||
|
expect(iconComponent.props('icon')).toBe('mdi:cloud-arrow-up-outline')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits update:modelValue when a file is selected', async () => {
|
||||||
|
const wrapper = mountComponent({modelValue: ''})
|
||||||
|
const fileInput = wrapper.find('input[type="file"]')
|
||||||
|
const file = new File(['content'], 'test.pdf', {type: 'application/pdf'})
|
||||||
|
|
||||||
|
Object.defineProperty(fileInput.element, 'files', {
|
||||||
|
value: [file],
|
||||||
|
})
|
||||||
|
await fileInput.trigger('change')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['test.pdf'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits file-selected with the File object when a file is selected', async () => {
|
||||||
|
const wrapper = mountComponent({modelValue: ''})
|
||||||
|
const fileInput = wrapper.find('input[type="file"]')
|
||||||
|
const file = new File(['content'], 'test.pdf', {type: 'application/pdf'})
|
||||||
|
|
||||||
|
Object.defineProperty(fileInput.element, 'files', {
|
||||||
|
value: [file],
|
||||||
|
})
|
||||||
|
await fileInput.trigger('change')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('file-selected')?.[0]).toEqual([file])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets disabled on both inputs when disabled is true', () => {
|
||||||
|
const wrapper = mountComponent({disabled: true})
|
||||||
|
|
||||||
|
expect(wrapper.get('input[type="text"]').attributes('disabled')).toBeDefined()
|
||||||
|
expect(wrapper.get('input[type="file"]').attributes('disabled')).toBeDefined()
|
||||||
|
expect(wrapper.get('input[type="text"]').classes()).toContain('cursor-not-allowed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error message and styles', () => {
|
||||||
|
const wrapper = mountComponent({error: 'Fichier requis'})
|
||||||
|
|
||||||
|
expect(wrapper.get('p.text-m-error').text()).toBe('Fichier requis')
|
||||||
|
expect(wrapper.get('input[type="text"]').classes()).toContain('border-m-error')
|
||||||
|
expect(wrapper.get('input[type="text"]').attributes('aria-invalid')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error style on icon', () => {
|
||||||
|
const wrapper = mountComponent({error: 'Error'})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows success message and styles', () => {
|
||||||
|
const wrapper = mountComponent({success: 'Fichier valide'})
|
||||||
|
|
||||||
|
expect(wrapper.get('p.text-m-success').text()).toBe('Fichier valide')
|
||||||
|
expect(wrapper.get('input[type="text"]').classes()).toContain('border-m-success')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows success style on icon', () => {
|
||||||
|
const wrapper = mountComponent({success: 'Success'})
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-success')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows hint message', () => {
|
||||||
|
const wrapper = mountComponent({hint: 'PDF uniquement'})
|
||||||
|
|
||||||
|
expect(wrapper.get('p.text-m-muted').text()).toBe('PDF uniquement')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('links label to input via for/id', () => {
|
||||||
|
const wrapper = mountComponent({id: 'upload', label: 'Fichier'})
|
||||||
|
|
||||||
|
expect(wrapper.get('input[type="text"]').attributes('id')).toBe('upload')
|
||||||
|
expect(wrapper.get('label').attributes('for')).toBe('upload')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates an id when missing and reuses it on label', () => {
|
||||||
|
const wrapper = mountComponent({label: 'Fichier'})
|
||||||
|
|
||||||
|
const inputId = wrapper.get('input[type="text"]').attributes('id')
|
||||||
|
|
||||||
|
expect(inputId?.startsWith('malio-input-upload-')).toBe(true)
|
||||||
|
expect(wrapper.get('label').attributes('for')).toBe(inputId)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aria-invalid is false when no error', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
|
||||||
|
expect(wrapper.get('input[type="text"]').attributes('aria-invalid')).toBe('false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes accept attribute to file input', () => {
|
||||||
|
const wrapper = mountComponent({accept: '.pdf,.doc'})
|
||||||
|
|
||||||
|
expect(wrapper.get('input[type="file"]').attributes('accept')).toBe('.pdf,.doc')
|
||||||
|
})
|
||||||
|
})
|
||||||
209
app/components/malio/InputUpload.vue
Normal file
209
app/components/malio/InputUpload.vue
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="mergedGroupClass"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
:accept="accept"
|
||||||
|
class="hidden"
|
||||||
|
:disabled="disabled"
|
||||||
|
@change="onFileChange"
|
||||||
|
>
|
||||||
|
|
||||||
|
<input
|
||||||
|
:id="inputId"
|
||||||
|
:class="mergedInputClass"
|
||||||
|
:disabled="disabled"
|
||||||
|
:value="currentDisplayValue"
|
||||||
|
:readonly="true"
|
||||||
|
:aria-invalid="!!error"
|
||||||
|
:aria-describedby="describedBy"
|
||||||
|
v-bind="attrs"
|
||||||
|
placeholder="_"
|
||||||
|
type="text"
|
||||||
|
@click="openFilePicker"
|
||||||
|
@focus="isFocused = true"
|
||||||
|
@blur="isFocused = false"
|
||||||
|
>
|
||||||
|
|
||||||
|
<label
|
||||||
|
v-if="label"
|
||||||
|
:for="inputId"
|
||||||
|
:class="mergedLabelClass"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<IconifyIcon
|
||||||
|
v-if="displayIcon"
|
||||||
|
icon="mdi:cloud-arrow-up-outline"
|
||||||
|
:width="24"
|
||||||
|
:height="24"
|
||||||
|
data-test="icon"
|
||||||
|
:class="[
|
||||||
|
hasError
|
||||||
|
? 'text-m-error'
|
||||||
|
: hasSuccess
|
||||||
|
? 'text-m-success' : 'text-m-muted',
|
||||||
|
'pointer-events-none absolute right-[10px] top-1/2 -translate-y-1/2',
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="hint || hasError || hasSuccess"
|
||||||
|
:id="`${inputId}-describedby`"
|
||||||
|
:class="[
|
||||||
|
hasError
|
||||||
|
? 'text-m-error'
|
||||||
|
: hasSuccess
|
||||||
|
? 'text-m-success'
|
||||||
|
: 'text-m-muted',
|
||||||
|
'mt-1 text-xs ml-[2px] ',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ hint || error || success }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import {computed, ref, useAttrs, useId} from 'vue'
|
||||||
|
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||||
|
import {twMerge} from 'tailwind-merge'
|
||||||
|
|
||||||
|
defineOptions({name: 'MalioInputUpload', inheritAttrs: false})
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id?: string
|
||||||
|
label?: string
|
||||||
|
modelValue?: string | null | undefined
|
||||||
|
inputClass?: string
|
||||||
|
labelClass?: string
|
||||||
|
groupClass?: string
|
||||||
|
disabled?: boolean
|
||||||
|
hint?: string
|
||||||
|
error?: string
|
||||||
|
success?: string
|
||||||
|
displayIcon?: boolean
|
||||||
|
accept?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
id: '',
|
||||||
|
modelValue: undefined,
|
||||||
|
label: '',
|
||||||
|
inputClass: '',
|
||||||
|
labelClass: '',
|
||||||
|
groupClass: '',
|
||||||
|
disabled: false,
|
||||||
|
hint: '',
|
||||||
|
error: '',
|
||||||
|
success: '',
|
||||||
|
displayIcon: true,
|
||||||
|
accept: '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const attrs = useAttrs()
|
||||||
|
const generatedId = useId()
|
||||||
|
const localValue = ref('')
|
||||||
|
const isFocused = ref(false)
|
||||||
|
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 mergedGroupClass = computed(() =>
|
||||||
|
twMerge(
|
||||||
|
'relative mt-4 flex h-12 w-full items-center',
|
||||||
|
props.groupClass,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
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 focus:border-2 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',
|
||||||
|
hasError.value
|
||||||
|
? 'border-m-error focus:border-m-error [&:not(:placeholder-shown)]:border-m-error'
|
||||||
|
: hasSuccess.value
|
||||||
|
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
||||||
|
: 'focus:border-m-primary',
|
||||||
|
props.displayIcon ? '!pr-10' : '',
|
||||||
|
'focus:pl-[11px]',
|
||||||
|
props.inputClass,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
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' : '',
|
||||||
|
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
|
||||||
|
hasError.value
|
||||||
|
? 'text-m-error'
|
||||||
|
: hasSuccess.value
|
||||||
|
? 'text-m-success'
|
||||||
|
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
|
||||||
|
props.labelClass,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const describedBy = computed(() => {
|
||||||
|
const ids: string[] = []
|
||||||
|
if (props.hint && !hasSuccess.value && !hasError.value) ids.push(`${inputId.value}-hint`)
|
||||||
|
if (hasError.value) ids.push(`${inputId.value}-error`)
|
||||||
|
if (hasSuccess.value && !hasError.value) ids.push(`${inputId.value}-success`)
|
||||||
|
return ids.length ? ids.join(' ') : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:modelValue', value: string): void
|
||||||
|
(event: 'file-selected', file: File): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const openFilePicker = () => {
|
||||||
|
if (props.disabled) return
|
||||||
|
fileInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFileChange = (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
const file = target.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
const fileName = file.name
|
||||||
|
if (!isControlled.value) {
|
||||||
|
localValue.value = fileName
|
||||||
|
}
|
||||||
|
emit('update:modelValue', fileName)
|
||||||
|
emit('file-selected', file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const disabled = computed(() => props.disabled)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.floating-label {
|
||||||
|
background: white;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grow-height {
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grow-height:focus {
|
||||||
|
padding-top: 0.625rem;
|
||||||
|
padding-bottom: 0.625rem;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.grow-height { transition: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
236
app/story/inputUpload.story.vue
Normal file
236
app/story/inputUpload.story.vue
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
<template>
|
||||||
|
<Story title="Input/Upload">
|
||||||
|
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Simple</h2>
|
||||||
|
<MalioInputUpload
|
||||||
|
v-model="simpleValue"
|
||||||
|
label="Fichier"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Sans icône</h2>
|
||||||
|
<MalioInputUpload
|
||||||
|
v-model="noIconValue"
|
||||||
|
label="Fichier"
|
||||||
|
:display-icon="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
||||||
|
<MalioInputUpload
|
||||||
|
v-model="hintValue"
|
||||||
|
label="Fichier"
|
||||||
|
hint="Formats acceptés : PDF, DOC, DOCX"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
||||||
|
<MalioInputUpload
|
||||||
|
v-model="disabledValue"
|
||||||
|
label="Fichier"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Readonly (avec fichier)</h2>
|
||||||
|
<MalioInputUpload
|
||||||
|
v-model="readonlyValue"
|
||||||
|
label="Fichier"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||||
|
<MalioInputUpload
|
||||||
|
v-model="errorValue"
|
||||||
|
label="Fichier"
|
||||||
|
error="Format non supporté"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Succès</h2>
|
||||||
|
<MalioInputUpload
|
||||||
|
v-model="successValue"
|
||||||
|
label="Fichier"
|
||||||
|
success="Fichier valide"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4">
|
||||||
|
<h2 class="mb-4 text-xl font-bold">Avec accept (PDF)</h2>
|
||||||
|
<MalioInputUpload
|
||||||
|
v-model="acceptValue"
|
||||||
|
label="Document PDF"
|
||||||
|
accept=".pdf"
|
||||||
|
hint="Seuls les fichiers PDF sont acceptés"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<docs lang="md">
|
||||||
|
# MalioInputUpload
|
||||||
|
|
||||||
|
Composant input d'upload de fichier avec label flottant, icône cloud,
|
||||||
|
affichage du nom du fichier sélectionné, états visuels (erreur / succès)
|
||||||
|
et accessibilité.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Props détaillées
|
||||||
|
|
||||||
|
### id
|
||||||
|
|
||||||
|
- Type: string
|
||||||
|
- Description: Identifiant HTML de l'input.
|
||||||
|
- Comportement: Si non fourni, un id unique est généré automatiquement.
|
||||||
|
|
||||||
|
### label
|
||||||
|
|
||||||
|
- Type: string
|
||||||
|
- Description: Texte affiché comme label flottant.
|
||||||
|
- Comportement: Si absent, aucun label n'est rendu.
|
||||||
|
|
||||||
|
### modelValue
|
||||||
|
|
||||||
|
- Type: string | null | undefined
|
||||||
|
- Description: Nom du fichier sélectionné (valeur contrôlée).
|
||||||
|
- Comportement:
|
||||||
|
- Si défini → composant contrôlé (v-model).
|
||||||
|
- Sinon → gestion interne de l'état.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Apparence & Style
|
||||||
|
|
||||||
|
### inputClass
|
||||||
|
|
||||||
|
- Type: string
|
||||||
|
- Description: Classes CSS appliquées à l'input texte.
|
||||||
|
|
||||||
|
### labelClass
|
||||||
|
|
||||||
|
- Type: string
|
||||||
|
- Description: Classes CSS appliquées au label.
|
||||||
|
|
||||||
|
### groupClass
|
||||||
|
|
||||||
|
- Type: string
|
||||||
|
- Description: Classes CSS appliquées au conteneur.
|
||||||
|
|
||||||
|
### displayIcon
|
||||||
|
|
||||||
|
- Type: boolean
|
||||||
|
- Défaut: true
|
||||||
|
- Description: Affiche ou masque l'icône d'upload.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Validation & Contraintes
|
||||||
|
|
||||||
|
### disabled
|
||||||
|
|
||||||
|
- Type: boolean
|
||||||
|
- Description: Désactive complètement le champ.
|
||||||
|
|
||||||
|
### accept
|
||||||
|
|
||||||
|
- Type: string
|
||||||
|
- Description: Types de fichiers acceptés (ex: `.pdf,.doc`).
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## États & Messages
|
||||||
|
|
||||||
|
### hint
|
||||||
|
|
||||||
|
- Type: string
|
||||||
|
- Description: Message d'aide affiché sous le champ.
|
||||||
|
|
||||||
|
### error
|
||||||
|
|
||||||
|
- Type: string
|
||||||
|
- Description: Message d'erreur.
|
||||||
|
- Effet:
|
||||||
|
- Active l'état visuel erreur.
|
||||||
|
- aria-invalid=true
|
||||||
|
- Prioritaire sur success et hint.
|
||||||
|
|
||||||
|
### success
|
||||||
|
|
||||||
|
- Type: string
|
||||||
|
- Description: Message de succès.
|
||||||
|
- Effet:
|
||||||
|
- Actif uniquement si error est absent.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Icône
|
||||||
|
|
||||||
|
- `mdi:cloud-arrow-up-outline` : icône d'upload affichée à droite.
|
||||||
|
|
||||||
|
### Couleur de l'icône
|
||||||
|
|
||||||
|
- `text-m-muted` par défaut.
|
||||||
|
- `text-m-error` si la prop `error` est renseignée.
|
||||||
|
- `text-m-success` si la prop `success` est renseignée.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Comportement
|
||||||
|
|
||||||
|
- Au clic sur l'input texte, le sélecteur de fichier natif s'ouvre.
|
||||||
|
- Le nom du fichier sélectionné est affiché dans l'input.
|
||||||
|
- L'input texte est en readonly — la saisie manuelle n'est pas autorisée.
|
||||||
|
|
||||||
|
## Priorité visuelle
|
||||||
|
|
||||||
|
1. error
|
||||||
|
2. success
|
||||||
|
3. neutre
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Accessibilité
|
||||||
|
|
||||||
|
- aria-invalid est activé si error existe.
|
||||||
|
- aria-describedby référence dynamiquement le message affiché.
|
||||||
|
- Fonctionne avec ou sans v-model.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
### update:modelValue
|
||||||
|
|
||||||
|
- Émis quand un fichier est sélectionné (valeur = nom du fichier).
|
||||||
|
- Permet l'utilisation avec v-model.
|
||||||
|
|
||||||
|
### file-selected
|
||||||
|
|
||||||
|
- Émis quand un fichier est sélectionné (valeur = objet File).
|
||||||
|
- Permet d'accéder au fichier pour l'upload.
|
||||||
|
|
||||||
|
</docs>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref} from 'vue'
|
||||||
|
import MalioInputUpload from '../components/malio/InputUpload.vue'
|
||||||
|
|
||||||
|
const simpleValue = ref('')
|
||||||
|
const noIconValue = ref('')
|
||||||
|
const hintValue = ref('')
|
||||||
|
const disabledValue = ref('document.pdf')
|
||||||
|
const readonlyValue = ref('rapport.pdf')
|
||||||
|
const errorValue = ref('image.bmp')
|
||||||
|
const successValue = ref('rapport.pdf')
|
||||||
|
const acceptValue = ref('')
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user