Compare commits

...

19 Commits
v3 ... pr/1945

Author SHA1 Message Date
Benjamin Canac
02015e5803 Merge branch 'v3' into pr/1945 2025-07-21 17:03:34 +02:00
Benjamin Canac
42b6c96df2 up 2025-07-21 15:36:19 +02:00
Benjamin Canac
5989229744 up 2025-07-21 15:33:58 +02:00
Benjamin Canac
fe1cf02c51 up 2025-07-21 15:19:05 +02:00
Benjamin Canac
66b08fdf82 Merge branch 'v3' into pr/1945 2025-07-21 14:29:54 +02:00
Benjamin Canac
0da34cd8e5 up 2025-07-21 14:27:28 +02:00
Benjamin Canac
891d66cdb1 up 2025-07-21 13:10:10 +02:00
Benjamin Canac
90660c97c3 up 2025-07-21 11:26:42 +02:00
Benjamin Canac
20a1240015 Merge branch 'v3' into pr/1945 2025-07-21 11:03:03 +02:00
Benjamin Canac
1d692fb524 up 2025-07-18 18:38:27 +02:00
Benjamin Canac
3e836dfe4d Merge branch 'v3' into pr/1945 2025-07-18 17:28:20 +02:00
Benjamin Canac
8dca270965 up 2025-07-18 15:11:22 +02:00
Benjamin Canac
bfa6460613 up 2025-07-18 14:38:05 +02:00
Benjamin Canac
91a3f311b1 up 2025-07-17 16:06:42 +02:00
Benjamin Canac
c577df7eb1 Merge branch 'v3' into pr/1945 2025-07-17 12:31:24 +02:00
Benjamin Canac
a2b84d7f62 Merge branch 'v3' into pr/1945 2025-07-17 10:56:15 +02:00
Benjamin Canac
d12160927e Merge branch 'v3' into pr/1945 2025-07-16 21:52:54 +02:00
Benjamin Canac
01c8f3bf5e feat(FileUpload): new component
Co-Authored-By: Vachmara <55046446+vachmara@users.noreply.github.com>
2025-07-16 10:22:06 +02:00
Benjamin Canac
10450c537d cli(templates): fix component type 2025-07-14 14:49:23 +02:00
13 changed files with 655 additions and 1 deletions

View 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

View File

@@ -35,6 +35,7 @@ const components = [
'command-palette',
'drawer',
'dropdown-menu',
'file-upload',
'form',
'form-field',
'input',

View File

@@ -35,6 +35,7 @@ const components = [
'command-palette',
'drawer',
'dropdown-menu',
'file-upload',
'form',
'form-field',
'input',

View File

@@ -0,0 +1,126 @@
<script setup lang="ts">
import * as z from 'zod'
import type { FormSubmitEvent } from '@nuxt/ui'
import theme from '#build/ui/file-upload'
const sizes = Object.keys(theme.variants.size) as Array<keyof typeof theme.variants.size>
const size = ref<keyof typeof theme.variants.size>('md')
const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
const MIN_DIMENSIONS = { width: 200, height: 200 }
const MAX_DIMENSIONS = { width: 4096, height: 4096 }
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 value = ref<File[]>([new File(['foo'], 'file1.txt', { type: 'text/plain' })])
const upload = useUpload('/api/blob', { method: 'PUT' })
function createObjectUrl(file: File): string {
return URL.createObjectURL(file)
}
async function onSubmit(event: FormSubmitEvent<schema>) {
const res = await upload(event.data.avatar)
console.log(res)
}
</script>
<template>
<div class="flex flex-col items-center gap-8">
<div class="flex flex-wrap items-center gap-3">
<USelect v-model="size" :items="sizes" />
</div>
<UForm :schema="schema" :state="state" class="space-y-4 w-80" @submit="onSubmit">
<UFormField name="avatar" label="Avatar" description="JPG, GIF or PNG. 1MB Max." :size="size">
<UFileUpload v-slot="{ open, remove }" v-model="state.avatar" accept="image/*">
<div class="flex flex-wrap items-center gap-3">
<UAvatar size="lg" :src="state.avatar ? createObjectUrl(state.avatar) : undefined" icon="i-lucide-image" />
<UButton :label="state.avatar ? 'Change image' : 'Upload image'" color="neutral" @click="open()" />
</div>
<p v-if="state.avatar" class="text-xs text-muted mt-1.5">
{{ state.avatar.name }}
<UButton
label="Remove"
color="error"
variant="link"
size="xs"
class="p-0"
@click="remove()"
/>
</p>
</UFileUpload>
</UFormField>
<UButton label="Submit" type="submit" />
</UForm>
<UFileUpload
v-model="value"
label="Drop your image here"
description="SVG, PNG, JPG or GIF (max. 2MB)"
class="w-full"
multiple
:size="size"
>
<template #files-bottom="{ remove }">
<UButton label="Remove all" @click="remove()" />
</template>
</UFileUpload>
</div>
</template>

View File

@@ -12,6 +12,10 @@ export default defineNuxtConfig({
compatibilityDate: '2024-07-09',
hub: {
blob: true
},
vite: {
optimizeDeps: {
// prevents reloading page when navigating between components

View 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
}
})
})

View File

@@ -0,0 +1,289 @@
<script lang="ts">
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 { 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
/**
* The icon to display.
* @defaultValue appConfig.ui.icons.upload
* @IconifyIcon
*/
icon?: string
label?: string
description?: string
actions?: ButtonProps[]
/**
* @defaultValue 'primary'
*/
color?: FileUpload['variants']['color']
/**
* @defaultValue 'md'
*/
size?: FileUpload['variants']['size']
/** Highlight the ring color like a focus state. */
highlight?: boolean
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 true
*/
dropzone?: boolean
/**
* The icon to display for the file.
* @defaultValue appConfig.ui.icons.file
* @IconifyIcon
*/
fileIcon?: string
class?: any
ui?: FileUpload['slots']
}
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<M extends boolean = false> {
'default'(props: {
open: UseFileDialogReturn['open']
remove: (index?: number) => void
}): any
'leading'(props?: {}): any
'label'(props?: {}): any
'description'(props?: {}): any
'actions'(props?: {}): any
'files'(props: { files: M extends true ? File[] : File | null }): any
'files-top'(props: { remove: (index?: number) => void }): any
'files-bottom'(props: { remove: (index?: number) => void }): 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 setup lang="ts" generic="M extends boolean = false">
import { ref, computed } from 'vue'
import { Primitive } from 'reka-ui'
import { useAppConfig } from '#imports'
import { useFormField } from '../composables/useFormField'
import { useFileUpload } from '../composables/useFileUpload'
import { tv } from '../utils/tv'
import UAvatar from './Avatar.vue'
import UButton from './Button.vue'
import UIcon from './Icon.vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<FileUploadProps<M>>(), {
accept: '*',
multiple: false as never,
reset: false,
dropzone: true
})
const emits = defineEmits<FileUploadEmits<M>>()
const slots = defineSlots<FileUploadSlots<M>>()
const modelValue = defineModel<(M extends true ? File[] : File) | null>()
const appConfig = useAppConfig() as FileUpload['AppConfig']
const inputRef = ref<HTMLInputElement>()
const dropzoneRef = ref<HTMLDivElement>()
const { isDragging, open } = useFileUpload({
accept: props.accept,
reset: props.reset,
multiple: props.multiple,
dropzone: props.dropzone,
dropzoneRef,
inputRef,
onUpdate
})
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,
color: props.color,
size: props.size,
highlight: props.highlight
}))
function createObjectUrl(file: File): string {
return URL.createObjectURL(file)
}
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 onUpdate(files: File[], reset = false) {
if (props.multiple) {
if (reset) {
modelValue.value = files as (M extends true ? File[] : File) | null
} else {
const existingFiles = (modelValue.value as File[]) || []
modelValue.value = [...existingFiles, ...(files || [])] as (M extends true ? File[] : File) | null
}
} else {
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 remove(index?: number) {
if (!modelValue.value) {
return
}
if (!props.multiple || index === undefined) {
onUpdate([], true)
return
}
const files = [...modelValue.value as File[]]
files.splice(index, 1)
onUpdate(files, true)
}
defineExpose({
inputRef
})
</script>
<template>
<Primitive :as="as" :class="ui.root({ class: [props.ui?.root, props.class] })">
<slot :open="open" :remove="remove">
<div
ref="dropzoneRef"
role="button"
:data-dragging="isDragging"
:class="ui.base({ class: props.ui?.base })"
tabindex="0"
@click="open()"
>
<div :class="ui.wrapper({ class: props.ui?.wrapper })">
<div :class="ui.leading({ class: props.ui?.leading })">
<slot name="leading">
<UIcon :name="icon || appConfig.ui.icons.upload" :class="ui.leadingIcon({ class: props.ui?.leadingIcon })" />
</slot>
</div>
<div v-if="label || !!slots.label" :class="ui.label({ class: props.ui?.label })">
<slot name="label">
{{ label }}
</slot>
</div>
<div v-if="description || !!slots.description" :class="ui.description({ class: props.ui?.description })">
<slot name="description">
{{ description }}
</slot>
</div>
<div v-if="actions?.length || !!slots.actions" :class="ui.actions({ class: props.ui?.actions })">
<slot name="actions">
<UButton v-for="(action, index) in actions" :key="index" size="xs" v-bind="action" />
</slot>
</div>
</div>
</div>
<div v-if="modelValue" :class="ui.files({ class: props.ui?.files })">
<slot name="files-top" :remove="remove" />
<slot name="files" :files="modelValue">
<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="fileIcon || appConfig.ui.icons.file" :size="props.size" :class="ui.fileLeadingAvatar({ class: props.ui?.fileLeadingAvatar })" />
</slot>
<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
color="neutral"
variant="link"
:size="size"
:trailing-icon="appConfig.ui.icons.close"
:class="ui.fileTrailing({ class: props.ui?.fileTrailing })"
@click="remove(index)"
/>
</slot>
</slot>
</div>
</slot>
<slot name="files-bottom" :remove="remove" />
</div>
</slot>
<input
:id="id"
ref="inputRef"
type="file"
:name="name"
:accept="accept"
:multiple="(multiple as boolean)"
:required="required"
:disabled="disabled"
v-bind="{ ...$attrs, ...ariaAttrs }"
hidden
>
</Primitive>
</template>

View File

@@ -0,0 +1,78 @@
import type { Ref } from 'vue'
import { computed, unref } from 'vue'
import { useFileDialog, useDropZone } from '@vueuse/core'
import type { MaybeRef, MaybeRefOrGetter } from '@vueuse/core'
export interface UseFileUploadOptions {
/**
* Specifies the allowed file types. Provide a comma-separated list of MIME types or file extensions.
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/accept
* @defaultValue '*'
*/
accept?: MaybeRef<string>
reset?: boolean
multiple?: boolean
dropzone?: boolean
dropzoneRef: MaybeRefOrGetter<HTMLElement | null | undefined>
inputRef: Ref<HTMLInputElement | undefined>
onUpdate: (files: File[]) => void
}
function parseAcceptToDataTypes(accept: string): string[] | undefined {
if (!accept || accept === '*') {
return undefined
}
const types = accept
.split(',')
.map(type => type.trim())
.filter((type) => {
return !type.startsWith('.')
})
return types.length > 0 ? types : undefined
}
export function useFileUpload(options: UseFileUploadOptions) {
const {
accept = '*',
reset = false,
multiple = false,
dropzone = true,
dropzoneRef,
inputRef,
onUpdate
} = options
const dataTypes = computed(() => parseAcceptToDataTypes(unref(accept)))
const onDrop = (files: FileList | File[] | null) => {
if (!files || files.length === 0) {
return
}
if (files instanceof FileList) {
files = Array.from(files)
}
if (files.length > 1 && !multiple) {
files = [files[0]!]
}
onUpdate(files)
}
const { isOverDropZone: isDragging } = dropzone
? useDropZone(dropzoneRef, { dataTypes: dataTypes.value, onDrop })
: { isOverDropZone: false }
const { onChange, open } = useFileDialog({
accept: unref(accept),
multiple,
input: unref(inputRef),
reset
})
onChange(fileList => onDrop(fileList))
return {
isDragging,
open
}
}

View File

@@ -20,6 +20,7 @@ export * from '../components/Container.vue'
export * from '../components/ContextMenu.vue'
export * from '../components/Drawer.vue'
export * from '../components/DropdownMenu.vue'
export * from '../components/FileUpload.vue'
export * from '../components/Form.vue'
export * from '../components/FormField.vue'
export * from '../components/Icon.vue'

90
src/theme/file-upload.ts Normal file
View File

@@ -0,0 +1,90 @@
import type { ModuleOptions } from '../module'
export default (options: Required<ModuleOptions>) => ({
slots: {
root: 'relative flex flex-col gap-2',
base: ['w-full bg-default hover:bg-elevated/25 border border-default p-4 flex flex-col items-center justify-center rounded-lg focus-visible:outline-2', options.theme.transitions && 'transition-[background]'],
wrapper: 'flex flex-col items-center justify-center text-center px-4 py-3',
leading: 'inline-flex items-center rounded-full ring ring-default',
leadingIcon: 'shrink-0 text-default',
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 items-start gap-2',
file: 'min-w-0 flex items-center gap-2 border border-default rounded-md w-full',
fileLeadingAvatar: 'shrink-0',
fileTrailing: 'ms-auto p-0',
fileWrapper: 'flex flex-col min-w-0',
fileName: 'text-default truncate',
fileSize: 'text-muted truncate'
},
variants: {
color: {
...Object.fromEntries((options.theme.colors || []).map((color: string) => [color, ''])),
neutral: ''
},
size: {
xs: {
base: 'text-xs',
leading: 'p-1',
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',
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',
file: 'text-xs px-2.5 py-1.5'
},
lg: {
base: 'text-sm',
leading: 'p-2',
leadingIcon: 'size-5',
file: 'text-sm px-3 py-2',
fileSize: 'text-xs'
},
xl: {
base: 'text-base',
leading: 'p-2',
leadingIcon: 'size-6',
file: 'text-sm px-3 py-2'
}
},
dropzone: {
true: 'border-dashed data-[dragging=true]:bg-elevated/25'
},
highlight: {
true: ''
},
disabled: {
true: 'cursor-not-allowed opacity-75'
}
},
compoundVariants: [...(options.theme.colors || []).map((color: string) => ({
color,
class: `focus-visible:outline-${color}`
})), ...(options.theme.colors || []).map((color: string) => ({
color,
highlight: true,
class: `ring ring-inset ring-${color}`
})), {
color: 'neutral',
class: 'focus-visible:outline-inverted'
}, {
color: 'neutral',
highlight: true,
class: 'ring ring-inset ring-inverted'
}],
defaultVariants: {
color: 'primary',
size: 'md'
}
})

View File

@@ -11,10 +11,12 @@ export default {
close: 'i-lucide-x',
ellipsis: 'i-lucide-ellipsis',
external: 'i-lucide-arrow-up-right',
file: 'i-lucide-file',
folder: 'i-lucide-folder',
folderOpen: 'i-lucide-folder-open',
loading: 'i-lucide-loader-circle',
minus: 'i-lucide-minus',
plus: 'i-lucide-plus',
search: 'i-lucide-search'
search: 'i-lucide-search',
upload: 'i-lucide-upload'
}

View File

@@ -19,6 +19,7 @@ export { default as container } from './container'
export { default as contextMenu } from './context-menu'
export { default as drawer } from './drawer'
export { default as dropdownMenu } from './dropdown-menu'
export { default as fileUpload } from './file-upload'
export { default as form } from './form'
export { default as formField } from './form-field'
export { default as input } from './input'

View 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()
})
})