This commit is contained in:
Benjamin Canac
2025-07-21 13:10:10 +02:00
parent 90660c97c3
commit 891d66cdb1
3 changed files with 120 additions and 42 deletions

View File

@@ -72,12 +72,12 @@ async function onSubmit(event: FormSubmitEvent<schema>) {
</script> </script>
<template> <template>
<div class="flex flex-col items-center justify-center gap-8"> <div class="flex flex-col items-center gap-8">
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<USelect v-model="size" :items="sizes" /> <USelect v-model="size" :items="sizes" />
</div> </div>
<UForm :schema="schema" :state="state" class="space-y-4 w-80 flex flex-col items-center" @submit="onSubmit"> <UForm :schema="schema" :state="state" class="space-y-4 w-80" @submit="onSubmit">
<UFormField name="avatar" label="Avatar" description="JPG, GIF or PNG. 1MB Max." :size="size"> <UFormField name="avatar" label="Avatar" description="JPG, GIF or PNG. 1MB Max." :size="size">
<UFileUpload v-slot="{ open, reset, urls }" v-model="state.avatar" accept="image/*"> <UFileUpload v-slot="{ open, reset, urls }" v-model="state.avatar" accept="image/*">
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
@@ -108,7 +108,10 @@ async function onSubmit(event: FormSubmitEvent<schema>) {
label="Drop your image here" label="Drop your image here"
description="SVG, PNG, JPG or GIF (max. 2MB)" description="SVG, PNG, JPG or GIF (max. 2MB)"
multiple multiple
class="w-full"
:size="size" :size="size"
/> />
<UFileUpload multiple />
</div> </div>
</template> </template>

View File

@@ -53,7 +53,6 @@ export interface FileUploadProps<M extends boolean = false> {
* @defaultValue true * @defaultValue true
*/ */
dropzone?: boolean dropzone?: boolean
defaultValue?: File[]
class?: any class?: any
ui?: FileUpload['slots'] ui?: FileUpload['slots']
} }
@@ -66,22 +65,21 @@ export interface FileUploadSlots {
default(props: { default(props: {
open: UseFileDialogReturn['open'] open: UseFileDialogReturn['open']
reset: UseFileDialogReturn['reset'] reset: UseFileDialogReturn['reset']
urls: string[]
}): any }): any
leading(props?: {}): any leading(props?: {}): any
label(props?: {}): any label(props?: {}): any
description(props?: {}): any description(props?: {}): any
actions(props?: {}): any actions(props?: {}): any
files(props: { files: FileList }): any files(props?: {}): any
} }
</script> </script>
<script setup lang="ts" generic="M extends boolean = false"> <script setup lang="ts" generic="M extends boolean = false">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { Primitive } from 'reka-ui' import { Primitive } from 'reka-ui'
import { useFileDialog, useDropZone } from '@vueuse/core'
import { useAppConfig } from '#imports' import { useAppConfig } from '#imports'
import { useFormField } from '../composables/useFormField' import { useFormField } from '../composables/useFormField'
import { useFileUpload } from '../composables/useFileUpload'
import { tv } from '../utils/tv' import { tv } from '../utils/tv'
import UButton from './Button.vue' import UButton from './Button.vue'
@@ -89,6 +87,7 @@ defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<FileUploadProps<M>>(), { const props = withDefaults(defineProps<FileUploadProps<M>>(), {
accept: '*', accept: '*',
multiple: false as never,
dropzone: true dropzone: true
}) })
defineEmits<FileUploadEmits<M>>() defineEmits<FileUploadEmits<M>>()
@@ -99,51 +98,51 @@ const modelValue = defineModel<(M extends true ? File[] : File) | null>()
const appConfig = useAppConfig() as FileUpload['AppConfig'] const appConfig = useAppConfig() as FileUpload['AppConfig']
const inputRef = ref<HTMLInputElement>() const inputRef = ref<HTMLInputElement>()
const dropZoneRef = ref<HTMLDivElement>() const dropzoneRef = ref<HTMLDivElement>()
const { files, open, reset, onChange } = useFileDialog({ const { isDragging, open, reset } = useFileUpload({
multiple: props.multiple,
accept: props.accept, accept: props.accept,
reset: props.reset, multiple: props.multiple,
input: inputRef.value, dropzone: props.dropzone,
initialFiles: props.defaultValue dropzoneRef,
}) onUpdate
const { isOverDropZone } = useDropZone(dropZoneRef, {
onDrop,
// dataTypes: props.accept.split(','),
multiple: props.multiple
}) })
const { emitFormInput, id, name, disabled, ariaAttrs } = useFormField<FileUploadProps>(props, { deferInputValidation: true }) const { emitFormInput, id, name, disabled, ariaAttrs } = useFormField<FileUploadProps>(props, { deferInputValidation: true })
const urls = computed(() => Array.from(files.value || []).map(file => URL.createObjectURL(file)))
const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.fileUpload || {}) })({ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.fileUpload || {}) })({
dropzone: props.dropzone, dropzone: props.dropzone,
multiple: props.multiple,
color: props.color, color: props.color,
size: props.size, size: props.size,
highlight: props.highlight highlight: props.highlight
})) }))
onChange((files) => { function createObjectUrl(file: File): string {
modelValue.value = (props.multiple ? files : files?.[0]) as (M extends true ? File[] : File) | null return URL.createObjectURL(file)
}
function onUpdate(files: File[]) {
if (props.multiple) {
const existingFiles = (modelValue.value as File[]) || []
modelValue.value = [...existingFiles, ...(files || [])] as (M extends true ? File[] : File) | null
} else {
modelValue.value = files?.[0] as (M extends true ? File[] : File) | null
}
emitFormInput() emitFormInput()
})
function onDrop(files: File[] | null) {
modelValue.value = (props.multiple ? files : files?.[0]) as (M extends true ? File[] : File) | null
} }
function removeFile(index: number) { function removeFile(index: number) {
const file = files.value?.[index] if (!props.multiple || !modelValue.value) {
return
if (file) {
URL.revokeObjectURL(URL.createObjectURL(file))
} }
const fileListArr = Array.from(files.value!) const fileList = Array.from(modelValue.value as File[])
fileListArr.splice(index, 1)
fileList.splice(index, 1)
modelValue.value = fileList as (M extends true ? File[] : File) | null
emitFormInput()
} }
defineExpose({ defineExpose({
@@ -153,11 +152,11 @@ defineExpose({
<template> <template>
<Primitive :as="as" :class="ui.root({ class: [props.ui?.root, props.class] })"> <Primitive :as="as" :class="ui.root({ class: [props.ui?.root, props.class] })">
<slot :open="open" :reset="reset" :urls="urls"> <slot :open="open" :reset="reset">
<div <div
ref="dropZoneRef" ref="dropzoneRef"
role="button" role="button"
:data-dragging="isOverDropZone" :data-dragging="isDragging"
:class="ui.base({ class: props.ui?.base })" :class="ui.base({ class: props.ui?.base })"
tabindex="0" tabindex="0"
@click="open()" @click="open()"
@@ -188,12 +187,19 @@ defineExpose({
</div> </div>
</div> </div>
<div v-if="files && files.length > 0" :class="ui.files({ class: props.ui?.files })"> <div v-if="modelValue && (modelValue as File[]).length > 0" :class="ui.files({ class: props.ui?.files })">
<slot name="files" :files="files"> <slot name="files">
<div v-for="(file, index) in files" :key="file.name" class="flex items-center gap-2 border border-default rounded-md p-2"> <div v-for="(file, index) in Array.isArray(modelValue) ? modelValue : [modelValue]" :key="file.name" class="min-w-0 flex items-center gap-2 border border-default rounded-md p-2">
<UAvatar :src="urls[index]" :icon="appConfig.ui.icons.file" /> <UAvatar :src="createObjectUrl(file)" :icon="appConfig.ui.icons.file" />
<span class="text-sm">{{ file.name }}</span> <span class="text-sm truncate">{{ file.name }}</span>
<UButton size="xs" color="neutral" variant="link" :trailing-icon="appConfig.ui.icons.close" @click="removeFile(index)" /> <UButton
size="xs"
color="neutral"
variant="link"
:trailing-icon="appConfig.ui.icons.close"
class="ms-auto"
@click="removeFile(index)"
/>
</div> </div>
</slot> </slot>
</div> </div>
@@ -205,7 +211,7 @@ defineExpose({
type="file" type="file"
:name="name" :name="name"
:accept="accept" :accept="accept"
:multiple="multiple" :multiple="(multiple as boolean)"
:required="required" :required="required"
:disabled="disabled" :disabled="disabled"
v-bind="{ ...$attrs, ...ariaAttrs }" v-bind="{ ...$attrs, ...ariaAttrs }"

View File

@@ -0,0 +1,69 @@
import { computed, unref } from 'vue'
import { useFileDialog, useDropZone } from '@vueuse/core'
import type { MaybeRef, MaybeRefOrGetter } from '@vueuse/core'
export interface UseFileUploadOptions {
/**
* Specifies the allowed file types. Provide a comma-separated list of MIME types or file extensions.
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/accept
* @defaultValue '*'
*/
accept?: MaybeRef<string>
dropzone?: boolean
dropzoneRef: MaybeRefOrGetter<HTMLElement | null | undefined>
multiple?: boolean
onUpdate: (files: File[]) => void
}
function parseAcceptToDataTypes(accept: string): string[] | undefined {
if (!accept || accept === '*') {
return undefined
}
const types = accept
.split(',')
.map(type => type.trim())
.filter((type) => {
return !type.startsWith('.')
})
return types.length > 0 ? types : undefined
}
export function useFileUpload(options: UseFileUploadOptions) {
const { dropzone = true, dropzoneRef, multiple = false, accept = '*', onUpdate } = options
const dataTypes = computed(() => {
const acceptValue = unref(accept)
return parseAcceptToDataTypes(acceptValue)
})
const onDrop = (files: FileList | File[] | null) => {
if (!files || files.length === 0) {
return
}
if (files instanceof FileList) {
files = Array.from(files)
}
if (files.length > 1 && !multiple) {
files = [files[0]!]
}
onUpdate(files)
}
const { isOverDropZone: isDragging } = dropzone
? useDropZone(dropzoneRef, { dataTypes: dataTypes.value, onDrop })
: { isOverDropZone: false }
const { onChange, open, reset } = useFileDialog({
accept: unref(accept),
multiple
})
onChange(fileList => onDrop(fileList))
return {
isDragging,
open,
reset
}
}