diff --git a/playground/app/app.vue b/playground/app/app.vue index 5557d7e122..68530b70e6 100644 --- a/playground/app/app.vue +++ b/playground/app/app.vue @@ -62,7 +62,8 @@ const components = [ 'textarea', 'toast', 'tooltip', - 'tree' + 'tree', + 'file-upload' ] const items = components.map(component => ({ label: upperName(component), to: `/components/${component}` })) diff --git a/playground/app/pages/components/file-upload.vue b/playground/app/pages/components/file-upload.vue new file mode 100644 index 0000000000..b35acc7ade --- /dev/null +++ b/playground/app/pages/components/file-upload.vue @@ -0,0 +1,10 @@ +<script lang="ts" setup> +const model = ref([]) +const fileuploadEl = ref(null) +</script> + +<template> + <UFileUpload ref="fileuploadEl" v-model="model" accept="image/*" /> + {{ model }} + {{ fileuploadEl }} +</template> diff --git a/src/runtime/components/FileUpload.vue b/src/runtime/components/FileUpload.vue new file mode 100644 index 0000000000..83b857fdd4 --- /dev/null +++ b/src/runtime/components/FileUpload.vue @@ -0,0 +1,326 @@ +<!-- eslint-disable @typescript-eslint/unified-signatures --> +<script lang="ts"> +import type theme from '#build/ui/file-upload' +import type { AppConfig } from '@nuxt/schema' +import type { ComponentConfig } from '../types' +import { useVModel, useEventListener } from '@vueuse/core' + +type AnyString = string & {} + +export interface FileRejection { + file: File + errors: FileError[] +} + +export interface FileChangeDetails { + acceptedFiles: File[] + rejectedFiles: FileRejection[] +} + +export type FileRejectDetails = FileRejection[] + +export type FileError = + | 'TOO_MANY_FILES' + | 'FILE_INVALID_TYPE' + | 'FILE_TOO_LARGE' + | 'FILE_TOO_SMALL' + | 'FILE_INVALID' + | 'FILE_EXISTS' + | AnyString + +export type ImageFileMimeType = + | 'image/png' + | 'image/gif' + | 'image/jpeg' + | 'image/svg+xml' + | 'image/webp' + | 'image/avif' + | 'image/heic' + | 'image/bmp' + +export type ApplicationFileMimeType = + | 'application/pdf' + | 'application/zip' + | 'application/json' + | 'application/xml' + | 'application/msword' + | 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + | 'application/vnd.ms-excel' + | 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + | 'application/vnd.ms-powerpoint' + | 'application/vnd.openxmlformats-officedocument.presentationml.presentation' + | 'application/rtf' + | 'application/x-rar' + | 'application/x-7z-compressed' + | 'application/x-tar' + | 'application/vnd.microsoft.portable-executable' + +export type TextFileMimeType = 'text/css' | 'text/csv' | 'text/html' | 'text/markdown' | 'text/plain' + +export type FontFileMimeType = 'font/ttf' | 'font/otf' | 'font/woff' | 'font/woff2' | 'font/eot' | 'font/svg' + +export type VideoFileMimeType = 'video/mp4' | 'video/webm' | 'video/ogg' | 'video/quicktime' | 'video/x-msvideo' + +export type AudioFileMimeType = + | 'audio/mpeg' + | 'audio/ogg' + | 'audio/wav' + | 'audio/webm' + | 'audio/aac' + | 'audio/flac' + | 'audio/x-m4a' + +export type FileMimeTypeGroup = 'image/*' | 'audio/*' | 'video/*' | 'text/*' | 'application/*' | 'font/*' + +export type FileMimeType = + | ImageFileMimeType + | ApplicationFileMimeType + | TextFileMimeType + | FontFileMimeType + | VideoFileMimeType + | AudioFileMimeType + | FileMimeTypeGroup + | AnyString + +type FileUplaod = ComponentConfig<typeof theme, AppConfig, 'file-upload'> + +export interface FileUplaodProps { + /** + * The element or component this component should render as. + * @defaultValue 'div' + */ + as?: any + id?: string + name?: string + modelValue: File[] + // color: FileUplaod['variants']['color'] + // variant?: FileUplaod['variants']['variant'] + /** + * @defaultValue 'md' + */ + // size?: FileUplaod['variants']['size'] + /** + * @defaultValue 'true' + */ + multiple?: boolean + accept?: MaybeRef<FileMimeType[] | FileMimeType> + /** + * @defaultValue 'true' + */ + allowDrop?: boolean + /** + * @defaultValue 'true' + */ + directory?: boolean + disabled?: boolean + maxFiles?: number + /** + * format in bytes + */ + maxFileSize?: number + /** + * format in bytes + */ + minFileSize?: number +} + +export interface FileUploadEmits { + (e: 'update:modelValue', files: File[]): void + (e: 'drop', file: File[] | null, event: DragEvent): void + (e: 'enter', file: File[] | null, event: DragEvent): void + (e: 'leave', file: File[] | null, event: DragEvent): void + (e: 'over', file: File[] | null, event: DragEvent): void +} +</script> + +<script lang="ts" setup> +import { ref, shallowRef, toValue, useTemplateRef, type MaybeRef } from 'vue' +import { Primitive } from 'reka-ui' + +defineOptions({ + inheritAttrs: false +}) +const props = withDefaults(defineProps<FileUplaodProps>(), { + multiple: true, + directory: true, + accept: '*', + maxFiles: 1, + minFileSize: 0, + maxFileSize: Infinity, + allowDrop: true +}) +const emits = defineEmits<FileUploadEmits>() +const files = useVModel(props, 'modelValue', emits) +const accept = toValue(props.accept) +const inputEl = useTemplateRef('inputEl') +const counter = ref(0) +const isOverDropZone = shallowRef(false) + +const dropZoneRef = ref<HTMLInputElement>() +const rejectedFiles = ref<FileRejectDetails>([]) + +function isDefined<T>(v: T | undefined): v is T { + return v !== undefined && v !== null +} + +function isFileAccepted(file: File | null) { + if (file && accept) { + const types = Array.isArray(accept) ? accept : typeof accept === 'string' ? accept.split(',') : [] + + if (types.length === 0) return true + + const fileName = file.name || '' + const mimeType = (file.type || '').toLowerCase() + const baseMimeType = mimeType.replace(/\/.*$/, '') + + return types.some((type) => { + const validType = type.trim().toLowerCase() + + if (validType.charAt(0) === '.') { + return fileName.toLowerCase().endsWith(validType) + } + + if (validType.endsWith('/*')) { + return baseMimeType === validType.replace(/\/.*$/, '') + } + + return mimeType === validType + }) + } + return true +} + +function isValidFileType(file: File): [boolean, FileError | null] { + const isAcceptable = file.type === 'application/x-moz-file' || isFileAccepted(file) + return [isAcceptable, isAcceptable ? null : 'FILE_INVALID_TYPE'] +} + +function isValidFileSize(file: File, minSize?: number, maxSize?: number): [boolean, FileError | null] { + if (isDefined(file.size)) { + if (isDefined(props.minFileSize) && isDefined(props.maxFileSize)) { + if (file.size > props.maxFileSize) return [false, 'FILE_TOO_LARGE'] + if (file.size < props.minFileSize) return [false, 'FILE_TOO_SMALL'] + } else if (isDefined(props.minFileSize) && file.size < props.minFileSize) { + return [false, 'FILE_TOO_SMALL'] + } else if (isDefined(props.maxFileSize) && file.size > props.maxFileSize) { + return [false, 'FILE_TOO_LARGE'] + } + } + return [true, null] +} + +function isDragEvent(event: unknown): event is DragEvent { + return event instanceof DragEvent +} + +function getFiles(event: Event | DragEvent): File[] | null { + const mapper = (files: FileList | never[]) => { + const list = Array.from(files) + return list.length === 0 ? null : (props.multiple ? list : [list[0]]) + } + + if (isDragEvent(event)) { + const dragEvent = event as DragEvent + const fileList = dragEvent.dataTransfer?.files ?? [] + return mapper(fileList) + } + + const input = event.target as HTMLInputElement + const fileList = input?.files ?? [] + return mapper(fileList) +} + +function isFilesWithInRange(file: File[]) { + if (!props.multiple && file.length > 1) return false + if (props.multiple && file.length > props.maxFileSize) return false + return true +} + +function handleFiles(event: Event | DragEvent) { + const currentFiles = getFiles(event) + + if (currentFiles) { + currentFiles.forEach((currentFile) => { + const [accepted, acceptError] = isValidFileType(currentFile) + const [sizeMatch, sizeError] = isValidFileSize(currentFile) + if (accepted && sizeMatch) { + files.value.push(currentFile) + } else { + const errors = [acceptError, sizeError] + rejectedFiles.value.push({ file: currentFile, errors: errors.filter(Boolean) as FileError[] }) + } + }) + + if (!isFilesWithInRange(currentFiles)) { + currentFiles?.forEach((currentFile) => { + rejectedFiles.value.push({ + file: currentFile, + errors: ['TOO_MANY_FILES'] + }) + }) + } + } +} + +function handleDragEvent(event: DragEvent, eventType: 'enter' | 'over' | 'leave' | 'drop') { + if (!props.allowDrop) return + event.preventDefault() + + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'copy' + } + + switch (eventType) { + case 'enter': + counter.value++ + isOverDropZone.value = true + emits('enter', null, event) + break + case 'over': + emits('over', null, event) + break + case 'leave': + counter.value-- + if (counter.value === 0) + isOverDropZone.value = false + emits('leave', null, event) + break + case 'drop': + emits('drop', files.value, event) + counter.value = 0 + isOverDropZone.value = false + handleFiles(event) + + break + } +} + +function handeChangeEvent(event: Event) { + handleFiles(event) +} + +useEventListener<DragEvent>(dropZoneRef, 'dragenter', event => handleDragEvent(event, 'enter')) +useEventListener<DragEvent>(dropZoneRef, 'dragover', event => handleDragEvent(event, 'over')) +useEventListener<DragEvent>(dropZoneRef, 'dragleave', event => handleDragEvent(event, 'leave')) +useEventListener<DragEvent>(dropZoneRef, 'drop', event => handleDragEvent(event, 'drop')) + +defineExpose({ + rejectedFiles +}) +</script> + +<template> + <Primitive ref="dropZoneRef" :as="as" :data-active="isOverDropZone" class="size-96 flex flex-col justify-center items-center rounded-(--ui-radius) border border-(--ui-border-muted)" @click="inputEl?.click()"> + <input + ref="inputEl" + :multiple="multiple" + class="sr-only" + type="file" + :accept="accept as unknown as string" + tabindex="-1" + @change="handeChangeEvent" + > + <span>Drop your files here</span> + <UButton label="Open Dialog" size="lg" /> + </Primitive> +</template>