This commit is contained in:
Benjamin Canac
2025-07-21 15:19:05 +02:00
parent 66b08fdf82
commit fe1cf02c51
4 changed files with 114 additions and 42 deletions

View File

@@ -62,6 +62,8 @@ const state = reactive<Partial<schema>>({
avatar: undefined
})
const value = ref<File[]>([new File(['foo'], 'file1.txt', { type: 'text/plain' })])
const upload = useUpload('/api/blob', { method: 'PUT' })
function createObjectUrl(file: File): string {
@@ -109,9 +111,11 @@ async function onSubmit(event: FormSubmitEvent<schema>) {
</UForm>
<UFileUpload
v-model="value"
label="Drop your image here"
description="SVG, PNG, JPG or GIF (max. 2MB)"
class="w-full"
multiple
:size="size"
/>
</div>

View File

@@ -2,7 +2,7 @@
import type { AppConfig } from '@nuxt/schema'
import type { UseFileDialogReturn } from '@vueuse/core'
import theme from '#build/ui/file-upload'
import type { ButtonProps } from '../types'
import type { AvatarProps, ButtonProps } from '../types'
import type { ComponentConfig } from '../types/utils'
type FileUpload = ComponentConfig<typeof theme, AppConfig, 'fileUpload'>
@@ -59,18 +59,23 @@ export interface FileUploadProps<M extends boolean = false> {
export interface FileUploadEmits<M extends boolean = false> {
(e: 'update:modelValue', value: M extends true ? File[] : File | null): void
(e: 'change', event: Event): void
}
export interface FileUploadSlots {
default(props: {
export interface FileUploadSlots<M extends boolean = false> {
'default'(props: {
open: UseFileDialogReturn['open']
reset: UseFileDialogReturn['reset']
}): any
leading(props?: {}): any
label(props?: {}): any
description(props?: {}): any
actions(props?: {}): any
files(props?: {}): any
'leading'(props?: {}): any
'label'(props?: {}): any
'description'(props?: {}): any
'actions'(props?: {}): any
'files'(props: { files: M extends true ? File[] : File | null }): any
'file'(props: { file: File, index: number }): any
'file-leading'(props: { file: File, index: number }): any
'file-name'(props: { file: File, index: number }): any
'file-size'(props: { file: File, index: number }): any
'file-trailing'(props: { file: File, index: number }): any
}
</script>
@@ -90,10 +95,11 @@ defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<FileUploadProps<M>>(), {
accept: '*',
multiple: false as never,
reset: false,
dropzone: true
})
defineEmits<FileUploadEmits<M>>()
const slots = defineSlots<FileUploadSlots>()
const emits = defineEmits<FileUploadEmits<M>>()
const slots = defineSlots<FileUploadSlots<M>>()
const modelValue = defineModel<(M extends true ? File[] : File) | null>()
@@ -102,14 +108,16 @@ const appConfig = useAppConfig() as FileUpload['AppConfig']
const inputRef = ref<HTMLInputElement>()
const dropzoneRef = ref<HTMLDivElement>()
const { isDragging, open, reset } = useFileUpload({
const { isDragging, open } = useFileUpload({
accept: props.accept,
reset: props.reset,
multiple: props.multiple,
dropzone: props.dropzone,
dropzoneRef,
inputRef,
onUpdate
})
const { emitFormInput, id, name, disabled, ariaAttrs } = useFormField<FileUploadProps>(props, { deferInputValidation: true })
const { emitFormInput, emitFormChange, id, name, disabled, ariaAttrs } = useFormField<FileUploadProps>(props, { deferInputValidation: true })
const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.fileUpload || {}) })({
dropzone: props.dropzone,
@@ -130,11 +138,30 @@ function onUpdate(files: File[]) {
modelValue.value = files?.[0] as (M extends true ? File[] : File) | null
}
// @ts-expect-error - 'target' does not exist in type 'EventInit'
const event = new Event('change', { target: { value: modelValue.value } })
emits('change', event)
emitFormChange()
emitFormInput()
}
function formatFileSize(bytes: number): string {
if (bytes === 0) {
return '0B'
}
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
const size = bytes / Math.pow(k, i)
const formattedSize = i === 0 ? size.toString() : size.toFixed(2)
return `${formattedSize}${sizes[i]}`
}
function removeFile(index: number) {
if (!props.multiple || !modelValue.value) {
if (!modelValue.value) {
return
}
@@ -154,7 +181,7 @@ defineExpose({
<template>
<Primitive :as="as" :class="ui.root({ class: [props.ui?.root, props.class] })">
<slot :open="open" :reset="reset">
<slot :open="open" :remove="removeFile">
<div
ref="dropzoneRef"
role="button"
@@ -191,19 +218,37 @@ defineExpose({
<div v-if="modelValue" :class="ui.files({ class: props.ui?.files })">
<slot name="files">
<div v-for="(file, index) in Array.isArray(modelValue) ? modelValue : [modelValue]" :key="(file as File).name" class="min-w-0 flex items-center gap-2 border border-default rounded-md p-2">
<UAvatar :src="createObjectUrl(file)" :icon="appConfig.ui.icons.file" />
<div v-for="(file, index) in Array.isArray(modelValue) ? modelValue : [modelValue]" :key="(file as File).name" :class="ui.file({ class: props.ui?.file })">
<slot name="file" :file="file" :index="index">
<slot name="file-leading" :file="file" :index="index">
<UAvatar :src="createObjectUrl(file)" :icon="appConfig.ui.icons.file" :size="props.size" :class="ui.fileLeadingAvatar({ class: props.ui?.fileLeadingAvatar })" />
</slot>
<span class="text-sm truncate">{{ (file as File).name }}</span>
<div :class="ui.fileWrapper({ class: props.ui?.fileWrapper })">
<span :class="ui.fileName({ class: props.ui?.fileName })">
<slot name="file-name" :file="file" :index="index">
{{ (file as File).name }}
</slot>
</span>
<span :class="ui.fileSize({ class: props.ui?.fileSize })">
<slot name="file-size" :file="file" :index="index">
{{ formatFileSize((file as File).size) }}
</slot>
</span>
</div>
<slot name="file-trailing" :file="file" :index="index">
<UButton
size="xs"
color="neutral"
variant="link"
:size="size"
:trailing-icon="appConfig.ui.icons.close"
class="ms-auto"
:class="ui.fileTrailing({ class: props.ui?.fileTrailing })"
@click="removeFile(index)"
/>
</slot>
</slot>
</div>
</slot>
</div>

View File

@@ -1,3 +1,4 @@
import type { Ref } from 'vue'
import { computed, unref } from 'vue'
import { useFileDialog, useDropZone } from '@vueuse/core'
import type { MaybeRef, MaybeRefOrGetter } from '@vueuse/core'
@@ -9,9 +10,11 @@ export interface UseFileUploadOptions {
* @defaultValue '*'
*/
accept?: MaybeRef<string>
reset?: boolean
multiple?: boolean
dropzone?: boolean
dropzoneRef: MaybeRefOrGetter<HTMLElement | null | undefined>
multiple?: boolean
inputRef: Ref<HTMLInputElement | undefined>
onUpdate: (files: File[]) => void
}
@@ -31,12 +34,17 @@ function parseAcceptToDataTypes(accept: string): string[] | undefined {
}
export function useFileUpload(options: UseFileUploadOptions) {
const { dropzone = true, dropzoneRef, multiple = false, accept = '*', onUpdate } = options
const {
accept = '*',
reset = false,
multiple = false,
dropzone = true,
dropzoneRef,
inputRef,
onUpdate
} = options
const dataTypes = computed(() => {
const acceptValue = unref(accept)
return parseAcceptToDataTypes(acceptValue)
})
const dataTypes = computed(() => parseAcceptToDataTypes(unref(accept)))
const onDrop = (files: FileList | File[] | null) => {
if (!files || files.length === 0) {
@@ -54,16 +62,17 @@ export function useFileUpload(options: UseFileUploadOptions) {
const { isOverDropZone: isDragging } = dropzone
? useDropZone(dropzoneRef, { dataTypes: dataTypes.value, onDrop })
: { isOverDropZone: false }
const { onChange, open, reset } = useFileDialog({
const { onChange, open } = useFileDialog({
accept: unref(accept),
multiple
multiple,
input: unref(inputRef),
reset
})
onChange(fileList => onDrop(fileList))
return {
isDragging,
open,
reset
open
}
}

View File

@@ -10,7 +10,13 @@ export default (options: Required<ModuleOptions>) => ({
label: 'font-medium text-default mt-2',
description: 'text-muted mt-1',
actions: 'flex flex-wrap gap-1.5 shrink-0',
files: 'flex flex-col gap-2'
files: 'flex flex-col gap-2',
file: 'min-w-0 flex items-center gap-2 border border-default rounded-md',
fileLeadingAvatar: 'shrink-0',
fileTrailing: 'ms-auto inline-flex gap-1.5 items-center',
fileWrapper: 'flex flex-col min-w-0',
fileName: 'text-default truncate',
fileSize: 'text-muted truncate'
},
variants: {
color: {
@@ -21,27 +27,35 @@ export default (options: Required<ModuleOptions>) => ({
xs: {
base: 'text-xs',
leading: 'p-1',
leadingIcon: 'size-4'
leadingIcon: 'size-4',
file: 'text-xs px-2 py-1',
fileWrapper: 'flex-row gap-1'
},
sm: {
base: 'text-xs',
leading: 'p-1.5',
leadingIcon: 'size-4'
leadingIcon: 'size-4',
file: 'text-xs px-2.5 py-1.5',
fileWrapper: 'flex-row gap-1'
},
md: {
base: 'text-sm',
leading: 'p-1.5',
leadingIcon: 'size-5'
leadingIcon: 'size-5',
file: 'text-xs px-2.5 py-1.5'
},
lg: {
base: 'text-sm',
leading: 'p-2',
leadingIcon: 'size-5'
leadingIcon: 'size-5',
file: 'text-sm px-3 py-2',
fileSize: 'text-xs'
},
xl: {
base: 'text-base',
leading: 'p-2',
leadingIcon: 'size-6'
leadingIcon: 'size-6',
file: 'text-sm px-3 py-2'
}
},
dropzone: {