FileUpload
Usage
Use the v-model directive to control the value of the FileUpload.
The model accepts native File objects or custom FileUploadItem objects with the following properties:
name: stringsize?: numbertype?: stringavatar?: AvatarProps
The item type defaults to File and is inferred from your model when using custom items. You can extend FileUploadItem with custom properties for your application.
<script setup lang="ts">
const value = ref(null)
</script>
<template>
<PFileUpload v-model="value" class="w-96 min-h-48" />
</template><script setup lang="ts">
import { ref } from 'vue'
const value = ref(null)
</script>
<template>
<PFileUpload v-model="value" class="w-96 min-h-48" />
</template>Multiple
Use the multiple prop to allow multiple files to be selected.
<template>
<PFileUpload multiple class="w-96 min-h-48" />
</template>Dropzone
Use the dropzone prop to enable/disable the droppable area. Defaults to true.
<template>
<PFileUpload class="w-96 min-h-48" />
</template>Interactive
Use the interactive prop to enable/disable the clickable area. Defaults to true.
<template>
<PFileUpload class="w-96 min-h-48" />
</template>Accept
Use the accept prop to specify 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). Defaults to * (all file types).
<template>
<PFileUpload accept="image/*" class="w-96 min-h-48" />
</template>Label
Use the label prop to set the label of the FileUpload.
<template>
<PFileUpload label="Drop your image here" class="w-96 min-h-48" />
</template>Description
Use the description prop to set the description of the FileUpload.
<template>
<PFileUpload
label="Drop your image here"
description="SVG, PNG, JPG or GIF (max. 2MB)"
class="w-96 min-h-48"
/>
</template>Icon
Use the icon prop to set the icon of the FileUpload. Defaults to i-lucide-upload.
<template>
<PFileUpload
icon="i-lucide-image"
label="Drop your image here"
description="SVG, PNG, JPG or GIF (max. 2MB)"
class="w-96 min-h-48"
/>
</template>Color
Use the color prop to change the color of the FileUpload.
<template>
<PFileUpload
color="neutral"
highlight
label="Drop your image here"
description="SVG, PNG, JPG or GIF (max. 2MB)"
class="w-96 min-h-48"
/>
</template>highlight prop is used here to show the focus state. It's used internally when a validation error occurs.Variant
Use the variant prop to change the variant of the FileUpload.
<template>
<PFileUpload variant="button" />
</template>Size
Use the size prop to change the size of the FileUpload.
<template>
<PFileUpload
size="xl"
variant="area"
label="Drop your image here"
description="SVG, PNG, JPG or GIF (max. 2MB)"
/>
</template>Layout
Use the layout prop to change how the files are displayed in the FileUpload. Defaults to grid.
variant is area.<template>
<PFileUpload
layout="list"
multiple
label="Drop your images here"
description="SVG, PNG, JPG or GIF (max. 2MB)"
class="w-96"
:ui="{
base: 'min-h-48'
}"
/>
</template>Position
Use the position prop to change the position of the files in the FileUpload. Defaults to outside.
variant is area and when layout is list.<template>
<PFileUpload
position="inside"
layout="list"
multiple
label="Drop your images here"
description="SVG, PNG, JPG or GIF (max. 2MB)"
class="w-96"
:ui="{
base: 'min-h-48'
}"
/>
</template>Examples
With custom file items Soon
Use an object with a name, optional file metadata and an Avatar to display an existing file. Native files selected by the user are added to the same model. Models and file slots include File alongside your custom item type, so narrow the item before accessing custom properties.
<script setup lang="ts">
import type { FileUploadItem } from 'pohon-ui'
const files = ref<Array<FileUploadItem>>([
{
name: 'nuxt.png',
size: 1000,
type: 'image/png',
avatar: {
src: 'https://github.com/nuxt.png',
alt: 'Nuxt'
}
}
])
</script>
<template>
<PFileUpload
v-model="files"
layout="list"
label="Drop your images here"
description="SVG, PNG, JPG or GIF"
accept="image/*"
multiple
class="min-h-48 w-96"
/>
</template><script setup lang="ts">
import { ref } from 'vue'
import type { FileUploadItem } from 'pohon-ui'
const files = ref<Array<FileUploadItem>>([
{
name: 'nuxt.png',
size: 1000,
type: 'image/png',
avatar: {
src: 'https://github.com/nuxt.png',
alt: 'Nuxt'
}
}
])
</script>
<template>
<PFileUpload
v-model="files"
layout="list"
label="Drop your images here"
description="SVG, PNG, JPG or GIF"
accept="image/*"
multiple
class="min-h-48 w-96"
/>
</template>With upload status Soon
Extend FileUploadItem with custom fields and use the file slots to display upload progress or other asynchronous states. This example converts selected files into custom items, simulates upload progress, and displays their image previews when complete.
<script setup lang="ts">
import type { FileUploadItem } from 'pohon-ui'
interface UploadFileItem extends FileUploadItem {
progress: number
preview: string
}
const uploadingAvatar = {
icon: 'i-lucide-loader-circle',
ui: { icon: 'animate-spin' }
}
const files = ref<Array<UploadFileItem>>([
{
name: 'nuxt.png',
size: 1000,
type: 'image/png',
progress: 0,
preview: 'https://github.com/nuxt.png',
avatar: uploadingAvatar
}
])
const objectUrls = new Set<string>()
function onUpdateFiles(value: Array<File | UploadFileItem> | null | undefined) {
files.value = (value || []).map((file) => {
if (!(file instanceof File)) {
return file
}
const preview = URL.createObjectURL(file)
objectUrls.add(preview)
return {
name: file.name,
size: file.size,
type: file.type,
progress: 0,
preview,
avatar: uploadingAvatar
}
})
for (const url of objectUrls) {
if (files.value.some((file) => file.preview === url)) {
continue
}
URL.revokeObjectURL(url)
objectUrls.delete(url)
}
}
useIntervalFn(() => {
for (const file of files.value) {
if (file.progress === 100) {
continue
}
file.progress = Math.min(file.progress + 2, 100)
if (file.progress === 100) {
file.avatar = { src: file.preview, alt: file.name }
}
}
}, 100)
onScopeDispose(() => {
for (const url of objectUrls) {
URL.revokeObjectURL(url)
}
})
</script>
<template>
<PFileUpload
:model-value="files"
layout="list"
label="Drop your images here"
description="SVG, PNG, JPG or GIF"
accept="image/*"
multiple
class="min-h-48 w-96"
@update:model-value="onUpdateFiles"
>
<template #file-trailing="{ file, index, removeFile }">
<div class="ms-auto flex gap-2 items-center">
<PProgress
v-if="'progress' in file && file.progress < 100"
:model-value="file.progress"
size="xs"
class="w-20"
/>
<PIcon
v-else-if="'progress' in file && file.progress === 100"
name="i-lucide-circle-check"
class="color-success size-5"
/>
<PButton
color="neutral"
variant="link"
icon="i-lucide-x"
:aria-label="`Remove ${file.name}`"
@click.stop.prevent="removeFile(index)"
/>
</div>
</template>
</PFileUpload>
</template><script setup lang="ts">
import { ref } from 'vue'
import type { FileUploadItem } from 'pohon-ui'
interface UploadFileItem extends FileUploadItem {
progress: number
preview: string
}
const uploadingAvatar = {
icon: 'i-lucide-loader-circle',
ui: { icon: 'animate-spin' }
}
const files = ref<Array<UploadFileItem>>([
{
name: 'nuxt.png',
size: 1000,
type: 'image/png',
progress: 0,
preview: 'https://github.com/nuxt.png',
avatar: uploadingAvatar
}
])
const objectUrls = new Set<string>()
function onUpdateFiles(value: Array<File | UploadFileItem> | null | undefined) {
files.value = (value || []).map((file) => {
if (!(file instanceof File)) {
return file
}
const preview = URL.createObjectURL(file)
objectUrls.add(preview)
return {
name: file.name,
size: file.size,
type: file.type,
progress: 0,
preview,
avatar: uploadingAvatar
}
})
for (const url of objectUrls) {
if (files.value.some((file) => file.preview === url)) {
continue
}
URL.revokeObjectURL(url)
objectUrls.delete(url)
}
}
useIntervalFn(() => {
for (const file of files.value) {
if (file.progress === 100) {
continue
}
file.progress = Math.min(file.progress + 2, 100)
if (file.progress === 100) {
file.avatar = { src: file.preview, alt: file.name }
}
}
}, 100)
onScopeDispose(() => {
for (const url of objectUrls) {
URL.revokeObjectURL(url)
}
})
</script>
<template>
<PFileUpload
:model-value="files"
layout="list"
label="Drop your images here"
description="SVG, PNG, JPG or GIF"
accept="image/*"
multiple
class="min-h-48 w-96"
@update:model-value="onUpdateFiles"
>
<template #file-trailing="{ file, index, removeFile }">
<div class="ms-auto flex gap-2 items-center">
<PProgress
v-if="'progress' in file && file.progress < 100"
:model-value="file.progress"
size="xs"
class="w-20"
/>
<PIcon
v-else-if="'progress' in file && file.progress === 100"
name="i-lucide-circle-check"
class="color-success size-5"
/>
<PButton
color="neutral"
variant="link"
icon="i-lucide-x"
:aria-label="`Remove ${file.name}`"
@click.stop.prevent="removeFile(index)"
/>
</div>
</template>
</PFileUpload>
</template>With Form validation
You can use the FileUpload within a Form and FormField components to handle validation and error handling.
<script setup lang="ts">
import type { FormSubmitEvent } from 'pohon-ui'
import * as z from 'zod'
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']
function 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 / k ** i).toFixed(dm))} ${sizes[i]}`
}
const schema = z.object({
image: 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>>({
image: undefined
})
async function onSubmit(event: FormSubmitEvent<Schema>) {
console.log(event.data)
}
</script>
<template>
<PForm :schema="schema" :state="state" class="w-96 space-y-4" @submit="onSubmit">
<PFormField name="image" label="Image" description="JPG, GIF or PNG. 2MB Max.">
<PFileUpload v-model="state.image" accept="image/*" class="min-h-48" />
</PFormField>
<PButton type="submit" label="Submit" color="neutral" />
</PForm>
</template><script setup lang="ts">
import { reactive } from 'vue'
import type { FormSubmitEvent } from 'pohon-ui'
import * as z from 'zod'
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']
function 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 / k ** i).toFixed(dm))} ${sizes[i]}`
}
const schema = z.object({
image: 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>>({
image: undefined
})
async function onSubmit(event: FormSubmitEvent<Schema>) {
console.log(event.data)
}
</script>
<template>
<PForm :schema="schema" :state="state" class="w-96 space-y-4" @submit="onSubmit">
<PFormField name="image" label="Image" description="JPG, GIF or PNG. 2MB Max.">
<PFileUpload v-model="state.image" accept="image/*" class="min-h-48" />
</PFormField>
<PButton type="submit" label="Submit" color="neutral" />
</PForm>
</template>With rejected files
Dropped files that do not match the accept prop are ignored. Listen to the reject event to display an error message.
<script setup lang="ts">
const value = ref<Array<File>>([])
const toast = useToast()
function onReject(files: Array<File>) {
toast.add({
title: 'Only images are allowed',
description: files.map((file) => file.name).join(', '),
color: 'error'
})
}
</script>
<template>
<PFileUpload
v-model="value"
accept="image/*"
icon="i-lucide-image"
label="Drop your images here"
description="SVG, PNG, JPG or GIF"
multiple
class="min-h-48 w-96"
@reject="onReject"
/>
</template><script setup lang="ts">
import { ref } from 'vue'
const value = ref<Array<File>>([])
const toast = useToast()
function onReject(files: Array<File>) {
toast.add({
title: 'Only images are allowed',
description: files.map((file) => file.name).join(', '),
color: 'error'
})
}
</script>
<template>
<PFileUpload
v-model="value"
accept="image/*"
icon="i-lucide-image"
label="Drop your images here"
description="SVG, PNG, JPG or GIF"
multiple
class="min-h-48 w-96"
@reject="onReject"
/>
</template>With default slot
You can use the default slot to make your own FileUpload component.
<script setup lang="ts">
import type { FormSubmitEvent } from 'pohon-ui'
import * as z from 'zod'
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']
function 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 / 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
})
function createObjectUrl(file: File): string {
return URL.createObjectURL(file)
}
async function onSubmit(event: FormSubmitEvent<Schema>) {
console.log(event.data)
}
</script>
<template>
<PForm :schema="schema" :state="state" class="w-64 space-y-4" @submit="onSubmit">
<PFormField name="avatar" label="Avatar" description="JPG, GIF or PNG. 1MB Max.">
<PFileUpload v-slot="{ open, removeFile }" v-model="state.avatar" accept="image/*">
<div class="flex flex-wrap gap-3 items-center">
<PAvatar
size="lg"
:src="state.avatar ? createObjectUrl(state.avatar) : undefined"
icon="i-lucide-image"
/>
<PButton
:label="state.avatar ? 'Change image' : 'Upload image'"
color="neutral"
variant="outline"
@click="open()"
/>
</div>
<p v-if="state.avatar" class="text-xs color-text-muted mt-1.5">
{{ state.avatar.name }}
<PButton
label="Remove"
color="error"
variant="link"
size="xs"
class="p-0"
@click="removeFile()"
/>
</p>
</PFileUpload>
</PFormField>
<PButton type="submit" label="Submit" color="neutral" />
</PForm>
</template><script setup lang="ts">
import { reactive } from 'vue'
import type { FormSubmitEvent } from 'pohon-ui'
import * as z from 'zod'
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']
function 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 / 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
})
function createObjectUrl(file: File): string {
return URL.createObjectURL(file)
}
async function onSubmit(event: FormSubmitEvent<Schema>) {
console.log(event.data)
}
</script>
<template>
<PForm :schema="schema" :state="state" class="w-64 space-y-4" @submit="onSubmit">
<PFormField name="avatar" label="Avatar" description="JPG, GIF or PNG. 1MB Max.">
<PFileUpload v-slot="{ open, removeFile }" v-model="state.avatar" accept="image/*">
<div class="flex flex-wrap gap-3 items-center">
<PAvatar
size="lg"
:src="state.avatar ? createObjectUrl(state.avatar) : undefined"
icon="i-lucide-image"
/>
<PButton
:label="state.avatar ? 'Change image' : 'Upload image'"
color="neutral"
variant="outline"
@click="open()"
/>
</div>
<p v-if="state.avatar" class="text-xs color-text-muted mt-1.5">
{{ state.avatar.name }}
<PButton
label="Remove"
color="error"
variant="link"
size="xs"
class="p-0"
@click="removeFile()"
/>
</p>
</PFileUpload>
</PFormField>
<PButton type="submit" label="Submit" color="neutral" />
</PForm>
</template>With files-bottom slot
You can use the files-bottom slot to add a Button under the files list to remove all files for example.
<script setup lang="ts">
const value = ref<Array<File>>([])
</script>
<template>
<PFileUpload
v-model="value"
icon="i-lucide-image"
label="Drop your images here"
description="SVG, PNG, JPG or GIF (max. 2MB)"
layout="list"
multiple
:interactive="false"
class="min-h-48 w-96"
>
<template #actions="{ open }">
<PButton
label="Select images"
icon="i-lucide-upload"
color="neutral"
variant="outline"
@click="open()"
/>
</template>
<template #files-bottom="{ removeFile, files }">
<PButton
v-if="files?.length"
label="Remove all files"
color="neutral"
@click="removeFile()"
/>
</template>
</PFileUpload>
</template><script setup lang="ts">
import { ref } from 'vue'
const value = ref<Array<File>>([])
</script>
<template>
<PFileUpload
v-model="value"
icon="i-lucide-image"
label="Drop your images here"
description="SVG, PNG, JPG or GIF (max. 2MB)"
layout="list"
multiple
:interactive="false"
class="min-h-48 w-96"
>
<template #actions="{ open }">
<PButton
label="Select images"
icon="i-lucide-upload"
color="neutral"
variant="outline"
@click="open()"
/>
</template>
<template #files-bottom="{ removeFile, files }">
<PButton
v-if="files?.length"
label="Remove all files"
color="neutral"
@click="removeFile()"
/>
</template>
</PFileUpload>
</template>With files-top slot
You can use the files-top slot to add a Button above the files list to add new files for example.
<script setup lang="ts">
const value = ref<Array<File>>([])
</script>
<template>
<PFileUpload
v-model="value"
icon="i-lucide-image"
label="Drop your images here"
description="SVG, PNG, JPG or GIF (max. 2MB)"
layout="grid"
multiple
:interactive="false"
class="min-h-48 w-96"
>
<template #actions="{ open }">
<PButton
label="Select images"
icon="i-lucide-upload"
color="neutral"
variant="outline"
@click="open()"
/>
</template>
<template #files-top="{ open, files }">
<div v-if="files?.length" class="mb-2 flex items-center justify-between">
<p class="font-bold">Files ({{ files?.length }})</p>
<PButton
icon="i-lucide-plus"
label="Add more"
color="neutral"
variant="outline"
class="-my-2"
@click="open()"
/>
</div>
</template>
</PFileUpload>
</template><script setup lang="ts">
import { ref } from 'vue'
const value = ref<Array<File>>([])
</script>
<template>
<PFileUpload
v-model="value"
icon="i-lucide-image"
label="Drop your images here"
description="SVG, PNG, JPG or GIF (max. 2MB)"
layout="grid"
multiple
:interactive="false"
class="min-h-48 w-96"
>
<template #actions="{ open }">
<PButton
label="Select images"
icon="i-lucide-upload"
color="neutral"
variant="outline"
@click="open()"
/>
</template>
<template #files-top="{ open, files }">
<div v-if="files?.length" class="mb-2 flex items-center justify-between">
<p class="font-bold">Files ({{ files?.length }})</p>
<PButton
icon="i-lucide-plus"
label="Add more"
color="neutral"
variant="outline"
class="-my-2"
@click="open()"
/>
</div>
</template>
</PFileUpload>
</template>API
Props
| Prop | Default | Type |
|---|
Slots
| Slot | Type |
|---|
Emits
| Event | Type |
|---|
Expose
When accessing the component via a template ref, you can use the following:
| Name | Type |
|---|---|
inputRef | Ref<HTMLInputElement | null> |
dropzoneRef | Ref<HTMLDivElement | null> |
Theme
export default defineAppConfig({
ui: {
fileUpload: {
slots: {
root: 'relative flex flex-col',
base: [
'bg-background border-border border rounded-lg flex flex-1 flex-col gap-2 w-full items-stretch justify-center focus-visible:outline-3',
'transition-[background-color] ease-out'
],
wrapper: 'flex flex-col items-center justify-center text-center',
icon: 'shrink-0',
avatar: 'shrink-0',
label: 'font-500 color-text mt-2',
description: 'color-text-muted mt-1',
actions: 'flex flex-wrap gap-1.5 shrink-0 mt-4',
files: '',
file: 'relative',
fileLeadingAvatar: 'shrink-0',
fileWrapper: 'flex flex-col min-w-0',
fileName: 'color-text truncate',
fileSize: 'color-text-muted truncate',
fileTrailingButton: ''
},
variants: {
color: {
primary: '',
secondary: '',
success: '',
info: '',
warning: '',
error: '',
neutral: ''
},
variant: {
area: {
wrapper: 'px-4 py-3',
base: 'p-4'
},
button: {}
},
size: {
xs: {
base: 'text-xs',
icon: 'size-4',
file: 'text-xs px-2 py-1 gap-1',
fileWrapper: 'flex-row gap-1'
},
sm: {
base: 'text-xs',
icon: 'size-4',
file: 'text-xs px-2.5 py-1.5 gap-1.5',
fileWrapper: 'flex-row gap-1'
},
md: {
base: 'text-sm',
icon: 'size-5',
file: 'text-xs px-2.5 py-1.5 gap-1.5'
},
lg: {
base: 'text-sm',
icon: 'size-5',
file: 'text-sm px-3 py-2 gap-2',
fileSize: 'text-xs'
},
xl: {
base: 'text-base',
icon: 'size-6',
file: 'text-sm px-3 py-2 gap-2'
}
},
layout: {
list: {
root: 'gap-2 items-start',
files: 'flex flex-col w-full gap-2',
file: 'min-w-0 flex items-center border border-border rounded-md w-full',
fileTrailingButton: 'ms-auto'
},
grid: {
fileWrapper: 'hidden',
fileLeadingAvatar: 'pohon:rounded-lg pohon:size-full',
fileTrailingButton: 'border-background pohon:p-0 border-2 pohon:rounded-full absolute -end-1.5 -top-1.5'
}
},
position: {
inside: '',
outside: ''
},
dropzone: {
true: 'border-dashed data-[dragging=true]:bg-background-elevated/25'
},
interactive: {
true: ''
},
highlight: {
true: ''
},
multiple: {
true: ''
},
disabled: {
true: 'cursor-not-allowed opacity-75'
}
},
compoundVariants: [
{
color: 'primary',
class: 'outline-primary/25 focus-visible:outline-3 focus-visible:border-primary'
},
{
color: 'primary',
highlight: true,
class: 'border-primary'
},
{
color: 'neutral',
class: 'outline-border-inverted/25 focus-visible:outline-3 focus-visible:border-border-inverted'
},
{
color: 'neutral',
highlight: true,
class: 'border-border-inverted'
},
{
size: 'xs',
layout: 'list',
class: {
fileTrailingButton: '-me-1'
}
},
{
size: 'sm',
layout: 'list',
class: {
fileTrailingButton: '-me-1.5'
}
},
{
size: 'md',
layout: 'list',
class: {
fileTrailingButton: '-me-1.5'
}
},
{
size: 'lg',
layout: 'list',
class: {
fileTrailingButton: '-me-2'
}
},
{
size: 'xl',
layout: 'list',
class: {
fileTrailingButton: '-me-2'
}
},
{
variant: 'button',
size: 'xs',
class: {
base: 'p-1'
}
},
{
variant: 'button',
size: 'sm',
class: {
base: 'p-1.5'
}
},
{
variant: 'button',
size: 'md',
class: {
base: 'p-1.5'
}
},
{
variant: 'button',
size: 'lg',
class: {
base: 'p-2'
}
},
{
variant: 'button',
size: 'xl',
class: {
base: 'p-2'
}
},
{
layout: 'grid',
multiple: true,
class: {
files: 'grid grid-cols-2 md:grid-cols-3 gap-4 w-full',
file: 'pohon:p-0 aspect-square'
}
},
{
layout: 'grid',
multiple: false,
class: {
file: 'pohon:absolute inset-0 pohon:p-0'
}
},
{
interactive: true,
disabled: false,
class: 'hover:bg-background-elevated/25'
}
],
defaultVariants: {
color: 'primary',
variant: 'area',
size: 'md'
}
}
}
})import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import ui from 'pohon-ui/vite'
export default defineConfig({
plugins: [
vue(),
ui({
ui: {
fileUpload: {
slots: {
root: 'relative flex flex-col',
base: [
'bg-background border-border border rounded-lg flex flex-1 flex-col gap-2 w-full items-stretch justify-center focus-visible:outline-3',
'transition-[background-color] ease-out'
],
wrapper: 'flex flex-col items-center justify-center text-center',
icon: 'shrink-0',
avatar: 'shrink-0',
label: 'font-500 color-text mt-2',
description: 'color-text-muted mt-1',
actions: 'flex flex-wrap gap-1.5 shrink-0 mt-4',
files: '',
file: 'relative',
fileLeadingAvatar: 'shrink-0',
fileWrapper: 'flex flex-col min-w-0',
fileName: 'color-text truncate',
fileSize: 'color-text-muted truncate',
fileTrailingButton: ''
},
variants: {
color: {
primary: '',
secondary: '',
success: '',
info: '',
warning: '',
error: '',
neutral: ''
},
variant: {
area: {
wrapper: 'px-4 py-3',
base: 'p-4'
},
button: {}
},
size: {
xs: {
base: 'text-xs',
icon: 'size-4',
file: 'text-xs px-2 py-1 gap-1',
fileWrapper: 'flex-row gap-1'
},
sm: {
base: 'text-xs',
icon: 'size-4',
file: 'text-xs px-2.5 py-1.5 gap-1.5',
fileWrapper: 'flex-row gap-1'
},
md: {
base: 'text-sm',
icon: 'size-5',
file: 'text-xs px-2.5 py-1.5 gap-1.5'
},
lg: {
base: 'text-sm',
icon: 'size-5',
file: 'text-sm px-3 py-2 gap-2',
fileSize: 'text-xs'
},
xl: {
base: 'text-base',
icon: 'size-6',
file: 'text-sm px-3 py-2 gap-2'
}
},
layout: {
list: {
root: 'gap-2 items-start',
files: 'flex flex-col w-full gap-2',
file: 'min-w-0 flex items-center border border-border rounded-md w-full',
fileTrailingButton: 'ms-auto'
},
grid: {
fileWrapper: 'hidden',
fileLeadingAvatar: 'pohon:rounded-lg pohon:size-full',
fileTrailingButton: 'border-background pohon:p-0 border-2 pohon:rounded-full absolute -end-1.5 -top-1.5'
}
},
position: {
inside: '',
outside: ''
},
dropzone: {
true: 'border-dashed data-[dragging=true]:bg-background-elevated/25'
},
interactive: {
true: ''
},
highlight: {
true: ''
},
multiple: {
true: ''
},
disabled: {
true: 'cursor-not-allowed opacity-75'
}
},
compoundVariants: [
{
color: 'primary',
class: 'outline-primary/25 focus-visible:outline-3 focus-visible:border-primary'
},
{
color: 'primary',
highlight: true,
class: 'border-primary'
},
{
color: 'neutral',
class: 'outline-border-inverted/25 focus-visible:outline-3 focus-visible:border-border-inverted'
},
{
color: 'neutral',
highlight: true,
class: 'border-border-inverted'
},
{
size: 'xs',
layout: 'list',
class: {
fileTrailingButton: '-me-1'
}
},
{
size: 'sm',
layout: 'list',
class: {
fileTrailingButton: '-me-1.5'
}
},
{
size: 'md',
layout: 'list',
class: {
fileTrailingButton: '-me-1.5'
}
},
{
size: 'lg',
layout: 'list',
class: {
fileTrailingButton: '-me-2'
}
},
{
size: 'xl',
layout: 'list',
class: {
fileTrailingButton: '-me-2'
}
},
{
variant: 'button',
size: 'xs',
class: {
base: 'p-1'
}
},
{
variant: 'button',
size: 'sm',
class: {
base: 'p-1.5'
}
},
{
variant: 'button',
size: 'md',
class: {
base: 'p-1.5'
}
},
{
variant: 'button',
size: 'lg',
class: {
base: 'p-2'
}
},
{
variant: 'button',
size: 'xl',
class: {
base: 'p-2'
}
},
{
layout: 'grid',
multiple: true,
class: {
files: 'grid grid-cols-2 md:grid-cols-3 gap-4 w-full',
file: 'pohon:p-0 aspect-square'
}
},
{
layout: 'grid',
multiple: false,
class: {
file: 'pohon:absolute inset-0 pohon:p-0'
}
},
{
interactive: true,
disabled: false,
class: 'hover:bg-background-elevated/25'
}
],
defaultVariants: {
color: 'primary',
variant: 'area',
size: 'md'
}
}
}
})
]
})