mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-29 03:10:42 +01:00
feat(FileUpload): new component
Co-Authored-By: Vachmara <55046446+vachmara@users.noreply.github.com>
This commit is contained in:
31
docs/content/3.components/file-upload.md
Normal file
31
docs/content/3.components/file-upload.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
title: FileUpload
|
||||||
|
description: ''
|
||||||
|
links:
|
||||||
|
- label: GitHub
|
||||||
|
icon: i-simple-icons-github
|
||||||
|
to: https://github.com/nuxt/ui/tree/v3/src/runtime/components/FileUpload.vue
|
||||||
|
navigation.badge: Soon
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### Props
|
||||||
|
|
||||||
|
:component-props
|
||||||
|
|
||||||
|
### Slots
|
||||||
|
|
||||||
|
:component-slots
|
||||||
|
|
||||||
|
### Emits
|
||||||
|
|
||||||
|
:component-emits
|
||||||
|
|
||||||
|
## Theme
|
||||||
|
|
||||||
|
:component-theme
|
||||||
5
playground/app/pages/components/file-upload.vue
Normal file
5
playground/app/pages/components/file-upload.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<UFileUpload />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,11 +1,81 @@
|
|||||||
<template>
|
<script setup lang="ts">
|
||||||
<div class="text-center">
|
import * as z from 'zod'
|
||||||
<h1 class="font-semibold text-primary mb-1">
|
import type { FormSubmitEvent } from '@nuxt/ui'
|
||||||
Playground
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-center gap-1">
|
const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||||
<UKbd value="meta" /> <UKbd value="K" />
|
const MIN_DIMENSIONS = { width: 200, height: 200 }
|
||||||
</div>
|
const MAX_DIMENSIONS = { width: 4096, height: 4096 }
|
||||||
</div>
|
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number, decimals = 2) => {
|
||||||
|
if (bytes === 0) return '0 Bytes'
|
||||||
|
const k = 1024
|
||||||
|
const dm = decimals < 0 ? 0 : decimals
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
avatar: z
|
||||||
|
.instanceof(File, {
|
||||||
|
message: 'Please select an image file.'
|
||||||
|
})
|
||||||
|
.refine(file => file.size <= MAX_FILE_SIZE, {
|
||||||
|
message: `The image is too large. Please choose an image smaller than ${formatBytes(MAX_FILE_SIZE)}.`
|
||||||
|
})
|
||||||
|
.refine(file => ACCEPTED_IMAGE_TYPES.includes(file.type), {
|
||||||
|
message: 'Please upload a valid image file (JPEG, PNG, or WebP).'
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
file =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => {
|
||||||
|
const meetsDimensions
|
||||||
|
= img.width >= MIN_DIMENSIONS.width
|
||||||
|
&& img.height >= MIN_DIMENSIONS.height
|
||||||
|
&& img.width <= MAX_DIMENSIONS.width
|
||||||
|
&& img.height <= MAX_DIMENSIONS.height
|
||||||
|
resolve(meetsDimensions)
|
||||||
|
}
|
||||||
|
img.src = e.target?.result as string
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
message: `The image dimensions are invalid. Please upload an image between ${MIN_DIMENSIONS.width}x${MIN_DIMENSIONS.height} and ${MAX_DIMENSIONS.width}x${MAX_DIMENSIONS.height} pixels.`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
type schema = z.output<typeof schema>
|
||||||
|
|
||||||
|
const state = reactive<Partial<schema>>({
|
||||||
|
avatar: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const upload = useUpload('/api/blob', { method: 'PUT' })
|
||||||
|
|
||||||
|
async function onSubmit(event: FormSubmitEvent<schema>) {
|
||||||
|
const res = await upload(event.data.avatar)
|
||||||
|
|
||||||
|
console.log(res)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UForm :schema="schema" :state="state" class="flex flex-col gap-4" @submit="onSubmit">
|
||||||
|
<UFormField name="avatar" label="Avatar" description="JPG, GIF or PNG. 1MB Max.">
|
||||||
|
<UFileUpload v-slot="{ open, previewUrls }" v-model="state.avatar" class="flex flex-wrap items-center gap-3" accept="image/*">
|
||||||
|
<UAvatar size="lg" :src="previewUrls?.[0]" icon="i-lucide-image" />
|
||||||
|
|
||||||
|
<UButton label="Choose" color="neutral" @click="open()" />
|
||||||
|
</UFileUpload>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UButton label="Submit" type="submit" block />
|
||||||
|
</UForm>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ export default defineNuxtConfig({
|
|||||||
|
|
||||||
compatibilityDate: '2024-07-09',
|
compatibilityDate: '2024-07-09',
|
||||||
|
|
||||||
|
hub: {
|
||||||
|
blob: true
|
||||||
|
},
|
||||||
|
|
||||||
vite: {
|
vite: {
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
// prevents reloading page when navigating between components
|
// prevents reloading page when navigating between components
|
||||||
|
|||||||
12
playground/server/api/blob.put.ts
Normal file
12
playground/server/api/blob.put.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export default eventHandler(async (event) => {
|
||||||
|
return hubBlob().handleUpload(event, {
|
||||||
|
formKey: 'files', // read file or files form the `formKey` field of request body (body should be a `FormData` object)
|
||||||
|
multiple: true, // when `true`, the `formKey` field will be an array of `Blob` objects
|
||||||
|
ensure: {
|
||||||
|
types: ['image/jpeg', 'image/png'] // allowed types of the file
|
||||||
|
},
|
||||||
|
put: {
|
||||||
|
addRandomSuffix: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
119
src/runtime/components/FileUpload.vue
Normal file
119
src/runtime/components/FileUpload.vue
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { AppConfig } from '@nuxt/schema'
|
||||||
|
import type { UseFileDialogReturn } from '@vueuse/core'
|
||||||
|
import theme from '#build/ui/file-upload'
|
||||||
|
import type { ComponentConfig } from '../types/utils'
|
||||||
|
|
||||||
|
type FileUpload = ComponentConfig<typeof theme, AppConfig, 'fileUpload'>
|
||||||
|
|
||||||
|
export interface FileUploadProps<M extends boolean = false> {
|
||||||
|
/**
|
||||||
|
* The element or component this component should render as.
|
||||||
|
* @defaultValue 'div'
|
||||||
|
*/
|
||||||
|
as?: any
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
required?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
multiple?: M & boolean
|
||||||
|
/**
|
||||||
|
* Specifies the allowed file types for the input. Provide a comma-separated list of MIME types or file extensions (e.g., "image/png,application/pdf,.jpg").
|
||||||
|
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/accept
|
||||||
|
* @defaultValue '*'
|
||||||
|
*/
|
||||||
|
accept?: string
|
||||||
|
/**
|
||||||
|
* Reset the file input when the dialog is opened.
|
||||||
|
* @defaultValue false
|
||||||
|
*/
|
||||||
|
reset?: boolean
|
||||||
|
/**
|
||||||
|
* Create a zone that allows the user to drop files onto it.
|
||||||
|
* @defaultValue false
|
||||||
|
*/
|
||||||
|
dropzone?: boolean
|
||||||
|
class?: any
|
||||||
|
ui?: FileUpload['slots']
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileUploadEmits<M extends boolean = false> {
|
||||||
|
(e: 'update:modelValue', value: M extends true ? File[] : File | null): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileUploadSlots {
|
||||||
|
default(props: {
|
||||||
|
open: UseFileDialogReturn['open']
|
||||||
|
reset: UseFileDialogReturn['reset']
|
||||||
|
previewUrls: string[]
|
||||||
|
}): any
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts" generic="M extends boolean = false">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { Primitive } from 'reka-ui'
|
||||||
|
import { useFileDialog, useDropZone } from '@vueuse/core'
|
||||||
|
import { useAppConfig } from '#imports'
|
||||||
|
import { useFormField } from '../composables/useFormField'
|
||||||
|
|
||||||
|
import { tv } from '../utils/tv'
|
||||||
|
|
||||||
|
defineOptions({ inheritAttrs: false })
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<FileUploadProps<M>>(), {
|
||||||
|
accept: '*'
|
||||||
|
})
|
||||||
|
defineEmits<FileUploadEmits<M>>()
|
||||||
|
defineSlots<FileUploadSlots>()
|
||||||
|
|
||||||
|
const modelValue = defineModel<(M extends true ? File[] : File) | null>()
|
||||||
|
|
||||||
|
const appConfig = useAppConfig() as FileUpload['AppConfig']
|
||||||
|
|
||||||
|
const { emitFormInput, emitFormChange, id, name, disabled, ariaAttrs } = useFormField<FileUploadProps>(props, { deferInputValidation: true })
|
||||||
|
|
||||||
|
// eslint-disable-next-line vue/no-dupe-keys
|
||||||
|
const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.fileUpload || {}) })({
|
||||||
|
|
||||||
|
}))
|
||||||
|
|
||||||
|
const inputRef = ref<HTMLInputElement>()
|
||||||
|
|
||||||
|
const { files, open, reset, onCancel, onChange } = useFileDialog({
|
||||||
|
multiple: props.multiple,
|
||||||
|
accept: props.accept,
|
||||||
|
reset: props.reset,
|
||||||
|
input: inputRef.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const previewUrls = computed(() => Array.from(files.value || []).map(file => URL.createObjectURL(file)))
|
||||||
|
|
||||||
|
onChange((files) => {
|
||||||
|
console.log('files:', typeof files?.[0])
|
||||||
|
modelValue.value = props.multiple ? files : files?.[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
onCancel(() => {
|
||||||
|
/** do something on cancel */
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive :as="as" :class="ui.root({ class: [props.ui?.root, props.class] })">
|
||||||
|
<slot :open="open" :reset="reset" :preview-urls="previewUrls" />
|
||||||
|
|
||||||
|
<input
|
||||||
|
:id="id"
|
||||||
|
ref="inputRef"
|
||||||
|
type="file"
|
||||||
|
:name="name"
|
||||||
|
:accept="accept"
|
||||||
|
:multiple="multiple"
|
||||||
|
:required="required"
|
||||||
|
:disabled="disabled"
|
||||||
|
hidden
|
||||||
|
v-bind="{ ...$attrs, ...ariaAttrs }"
|
||||||
|
>
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
@@ -20,6 +20,7 @@ export * from '../components/Container.vue'
|
|||||||
export * from '../components/ContextMenu.vue'
|
export * from '../components/ContextMenu.vue'
|
||||||
export * from '../components/Drawer.vue'
|
export * from '../components/Drawer.vue'
|
||||||
export * from '../components/DropdownMenu.vue'
|
export * from '../components/DropdownMenu.vue'
|
||||||
|
export * from '../components/FileUpload.vue'
|
||||||
export * from '../components/Form.vue'
|
export * from '../components/Form.vue'
|
||||||
export * from '../components/FormField.vue'
|
export * from '../components/FormField.vue'
|
||||||
export * from '../components/Icon.vue'
|
export * from '../components/Icon.vue'
|
||||||
|
|||||||
5
src/theme/file-upload.ts
Normal file
5
src/theme/file-upload.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
slots: {
|
||||||
|
root: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ export { default as container } from './container'
|
|||||||
export { default as contextMenu } from './context-menu'
|
export { default as contextMenu } from './context-menu'
|
||||||
export { default as drawer } from './drawer'
|
export { default as drawer } from './drawer'
|
||||||
export { default as dropdownMenu } from './dropdown-menu'
|
export { default as dropdownMenu } from './dropdown-menu'
|
||||||
|
export { default as fileUpload } from './file-upload'
|
||||||
export { default as form } from './form'
|
export { default as form } from './form'
|
||||||
export { default as formField } from './form-field'
|
export { default as formField } from './form-field'
|
||||||
export { default as input } from './input'
|
export { default as input } from './input'
|
||||||
|
|||||||
18
test/components/FileUpload.spec.ts
Normal file
18
test/components/FileUpload.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import FileUpload from '../../src/runtime/components/FileUpload.vue'
|
||||||
|
import type { FileUploadProps, FileUploadSlots } from '../../src/runtime/components/FileUpload.vue'
|
||||||
|
import ComponentRender from '../component-render'
|
||||||
|
|
||||||
|
describe('FileUpload', () => {
|
||||||
|
it.each([
|
||||||
|
// Props
|
||||||
|
['with as', { props: { as: 'section' } }],
|
||||||
|
['with class', { props: { class: '' } }],
|
||||||
|
['with ui', { props: { ui: {} } }],
|
||||||
|
// Slots
|
||||||
|
['with default slot', { slots: { default: () => 'Default slot' } }]
|
||||||
|
])('renders %s correctly', async (nameOrHtml: string, options: { props?: FileUploadProps, slots?: Partial<FileUploadSlots> }) => {
|
||||||
|
const html = await ComponentRender(nameOrHtml, options, FileUpload)
|
||||||
|
expect(html).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user