mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-14 12:14:41 +01:00
up
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user