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 avatar: undefined
}) })
const value = ref<File[]>([new File(['foo'], 'file1.txt', { type: 'text/plain' })])
const upload = useUpload('/api/blob', { method: 'PUT' }) const upload = useUpload('/api/blob', { method: 'PUT' })
function createObjectUrl(file: File): string { function createObjectUrl(file: File): string {
@@ -109,9 +111,11 @@ async function onSubmit(event: FormSubmitEvent<schema>) {
</UForm> </UForm>
<UFileUpload <UFileUpload
v-model="value"
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)"
class="w-full" class="w-full"
multiple
:size="size" :size="size"
/> />
</div> </div>

View File

@@ -2,7 +2,7 @@
import type { AppConfig } from '@nuxt/schema' import type { AppConfig } from '@nuxt/schema'
import type { UseFileDialogReturn } from '@vueuse/core' import type { UseFileDialogReturn } from '@vueuse/core'
import theme from '#build/ui/file-upload' import theme from '#build/ui/file-upload'
import type { ButtonProps } from '../types' import type { AvatarProps, ButtonProps } from '../types'
import type { ComponentConfig } from '../types/utils' import type { ComponentConfig } from '../types/utils'
type FileUpload = ComponentConfig<typeof theme, AppConfig, 'fileUpload'> 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> { export interface FileUploadEmits<M extends boolean = false> {
(e: 'update:modelValue', value: M extends true ? File[] : File | null): void (e: 'update:modelValue', value: M extends true ? File[] : File | null): void
(e: 'change', event: Event): void
} }
export interface FileUploadSlots { export interface FileUploadSlots<M extends boolean = false> {
default(props: { 'default'(props: {
open: UseFileDialogReturn['open'] open: UseFileDialogReturn['open']
reset: UseFileDialogReturn['reset']
}): 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?: {}): 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> </script>
@@ -90,10 +95,11 @@ defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<FileUploadProps<M>>(), { const props = withDefaults(defineProps<FileUploadProps<M>>(), {
accept: '*', accept: '*',
multiple: false as never, multiple: false as never,
reset: false,
dropzone: true dropzone: true
}) })
defineEmits<FileUploadEmits<M>>() const emits = defineEmits<FileUploadEmits<M>>()
const slots = defineSlots<FileUploadSlots>() const slots = defineSlots<FileUploadSlots<M>>()
const modelValue = defineModel<(M extends true ? File[] : File) | null>() const modelValue = defineModel<(M extends true ? File[] : File) | null>()
@@ -102,14 +108,16 @@ const appConfig = useAppConfig() as FileUpload['AppConfig']
const inputRef = ref<HTMLInputElement>() const inputRef = ref<HTMLInputElement>()
const dropzoneRef = ref<HTMLDivElement>() const dropzoneRef = ref<HTMLDivElement>()
const { isDragging, open, reset } = useFileUpload({ const { isDragging, open } = useFileUpload({
accept: props.accept, accept: props.accept,
reset: props.reset,
multiple: props.multiple, multiple: props.multiple,
dropzone: props.dropzone, dropzone: props.dropzone,
dropzoneRef, dropzoneRef,
inputRef,
onUpdate 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 || {}) })({ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.fileUpload || {}) })({
dropzone: props.dropzone, dropzone: props.dropzone,
@@ -130,11 +138,30 @@ function onUpdate(files: File[]) {
modelValue.value = files?.[0] as (M extends true ? File[] : File) | null 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() 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) { function removeFile(index: number) {
if (!props.multiple || !modelValue.value) { if (!modelValue.value) {
return return
} }
@@ -154,7 +181,7 @@ 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"> <slot :open="open" :remove="removeFile">
<div <div
ref="dropzoneRef" ref="dropzoneRef"
role="button" role="button"
@@ -191,19 +218,37 @@ defineExpose({
<div v-if="modelValue" :class="ui.files({ class: props.ui?.files })"> <div v-if="modelValue" :class="ui.files({ class: props.ui?.files })">
<slot name="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"> <div v-for="(file, index) in Array.isArray(modelValue) ? modelValue : [modelValue]" :key="(file as File).name" :class="ui.file({ class: props.ui?.file })">
<UAvatar :src="createObjectUrl(file)" :icon="appConfig.ui.icons.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>
<UButton <span :class="ui.fileSize({ class: props.ui?.fileSize })">
size="xs" <slot name="file-size" :file="file" :index="index">
color="neutral" {{ formatFileSize((file as File).size) }}
variant="link" </slot>
:trailing-icon="appConfig.ui.icons.close" </span>
class="ms-auto" </div>
@click="removeFile(index)"
/> <slot name="file-trailing" :file="file" :index="index">
<UButton
color="neutral"
variant="link"
:size="size"
:trailing-icon="appConfig.ui.icons.close"
:class="ui.fileTrailing({ class: props.ui?.fileTrailing })"
@click="removeFile(index)"
/>
</slot>
</slot>
</div> </div>
</slot> </slot>
</div> </div>

View File

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

View File

@@ -10,7 +10,13 @@ export default (options: Required<ModuleOptions>) => ({
label: 'font-medium text-default mt-2', label: 'font-medium text-default mt-2',
description: 'text-muted mt-1', description: 'text-muted mt-1',
actions: 'flex flex-wrap gap-1.5 shrink-0', 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: { variants: {
color: { color: {
@@ -21,27 +27,35 @@ export default (options: Required<ModuleOptions>) => ({
xs: { xs: {
base: 'text-xs', base: 'text-xs',
leading: 'p-1', leading: 'p-1',
leadingIcon: 'size-4' leadingIcon: 'size-4',
file: 'text-xs px-2 py-1',
fileWrapper: 'flex-row gap-1'
}, },
sm: { sm: {
base: 'text-xs', base: 'text-xs',
leading: 'p-1.5', 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: { md: {
base: 'text-sm', base: 'text-sm',
leading: 'p-1.5', leading: 'p-1.5',
leadingIcon: 'size-5' leadingIcon: 'size-5',
file: 'text-xs px-2.5 py-1.5'
}, },
lg: { lg: {
base: 'text-sm', base: 'text-sm',
leading: 'p-2', leading: 'p-2',
leadingIcon: 'size-5' leadingIcon: 'size-5',
file: 'text-sm px-3 py-2',
fileSize: 'text-xs'
}, },
xl: { xl: {
base: 'text-base', base: 'text-base',
leading: 'p-2', leading: 'p-2',
leadingIcon: 'size-6' leadingIcon: 'size-6',
file: 'text-sm px-3 py-2'
} }
}, },
dropzone: { dropzone: {