mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-16 13:08:06 +01:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 514e9a24f1 | |||
|
|
9f60443731 | ||
|
|
b22891abe6 | ||
|
|
9cda333631 |
@@ -6,14 +6,12 @@ const count = ref(0)
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const overlay = useOverlay()
|
const overlay = useOverlay()
|
||||||
|
|
||||||
const modal = overlay.create(LazyModalExample, {
|
const modal = overlay.create(LazyModalExample)
|
||||||
props: {
|
|
||||||
count: count.value
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function open() {
|
async function open() {
|
||||||
const instance = modal.open()
|
const instance = modal.open({
|
||||||
|
count: count.value
|
||||||
|
})
|
||||||
|
|
||||||
const shouldIncrement = await instance.result
|
const shouldIncrement = await instance.result
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,12 @@ const count = ref(0)
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const overlay = useOverlay()
|
const overlay = useOverlay()
|
||||||
|
|
||||||
const slideover = overlay.create(LazySlideoverExample, {
|
const slideover = overlay.create(LazySlideoverExample)
|
||||||
props: {
|
|
||||||
count: count.value
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function open() {
|
async function open() {
|
||||||
const instance = slideover.open()
|
const instance = slideover.open({
|
||||||
|
count: count.value
|
||||||
|
})
|
||||||
|
|
||||||
const shouldIncrement = await instance.result
|
const shouldIncrement = await instance.result
|
||||||
|
|
||||||
|
|||||||
@@ -183,7 +183,28 @@ It's recommended to install the [Tailwind CSS IntelliSense](https://marketplace.
|
|||||||
```
|
```
|
||||||
|
|
||||||
::note{to="/components/app"}
|
::note{to="/components/app"}
|
||||||
The `App` component provides global configurations and is required for **Toast**, **Tooltip** components to work as well as **Programmatic Overlays**.
|
The `App` component sets up global config and is required for **Toast**, **Tooltip** and **programmatic overlays**.
|
||||||
|
::
|
||||||
|
|
||||||
|
#### Add the `isolate` class to your root container
|
||||||
|
|
||||||
|
```html [index.html]{9}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Nuxt UI</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app" class="isolate"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
::note
|
||||||
|
This ensures styles are scoped to your app and prevents issues with overlays and stacking contexts.
|
||||||
::
|
::
|
||||||
|
|
||||||
::
|
::
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
---
|
|
||||||
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
|
|
||||||
@@ -51,3 +51,5 @@ items:
|
|||||||
url: https://wiredash.com/
|
url: https://wiredash.com/
|
||||||
- name: Zielgestalt
|
- name: Zielgestalt
|
||||||
url: https://zielgestalt.de/
|
url: https://zielgestalt.de/
|
||||||
|
- name: Arthur Danjou's Porfolio
|
||||||
|
url: https://arthurdanjou.fr/
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ const components = [
|
|||||||
'command-palette',
|
'command-palette',
|
||||||
'drawer',
|
'drawer',
|
||||||
'dropdown-menu',
|
'dropdown-menu',
|
||||||
'file-upload',
|
|
||||||
'form',
|
'form',
|
||||||
'form-field',
|
'form-field',
|
||||||
'input',
|
'input',
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ const components = [
|
|||||||
'command-palette',
|
'command-palette',
|
||||||
'drawer',
|
'drawer',
|
||||||
'dropdown-menu',
|
'dropdown-menu',
|
||||||
'file-upload',
|
|
||||||
'form',
|
'form',
|
||||||
'form-field',
|
'form-field',
|
||||||
'input',
|
'input',
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -12,10 +12,6 @@ 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
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -272,7 +272,7 @@ function getAccordionDefaultValue(list: NavigationMenuItem[], level = 0) {
|
|||||||
<component :is="orientation === 'vertical' && item.children?.length && !collapsed ? AccordionTrigger : 'span'" v-if="(!collapsed || orientation !== 'vertical') && (item.badge || (orientation === 'horizontal' && (item.children?.length || !!slots[(item.slot ? `${item.slot}-content` : 'item-content') as keyof NavigationMenuSlots<T>])) || (orientation === 'vertical' && item.children?.length) || item.trailingIcon || !!slots[(item.slot ? `${item.slot}-trailing` : 'item-trailing') as keyof NavigationMenuSlots<T>])" as="span" :class="ui.linkTrailing({ class: [props.ui?.linkTrailing, item.ui?.linkTrailing] })" @click.stop.prevent>
|
<component :is="orientation === 'vertical' && item.children?.length && !collapsed ? AccordionTrigger : 'span'" v-if="(!collapsed || orientation !== 'vertical') && (item.badge || (orientation === 'horizontal' && (item.children?.length || !!slots[(item.slot ? `${item.slot}-content` : 'item-content') as keyof NavigationMenuSlots<T>])) || (orientation === 'vertical' && item.children?.length) || item.trailingIcon || !!slots[(item.slot ? `${item.slot}-trailing` : 'item-trailing') as keyof NavigationMenuSlots<T>])" as="span" :class="ui.linkTrailing({ class: [props.ui?.linkTrailing, item.ui?.linkTrailing] })" @click.stop.prevent>
|
||||||
<slot :name="((item.slot ? `${item.slot}-trailing` : 'item-trailing') as keyof NavigationMenuSlots<T>)" :item="item" :active="active" :index="index">
|
<slot :name="((item.slot ? `${item.slot}-trailing` : 'item-trailing') as keyof NavigationMenuSlots<T>)" :item="item" :active="active" :index="index">
|
||||||
<UBadge
|
<UBadge
|
||||||
v-if="item.badge"
|
v-if="item.badge !== undefined"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
:size="((item.ui?.linkTrailingBadgeSize || props.ui?.linkTrailingBadgeSize || ui.linkTrailingBadgeSize()) as BadgeProps['size'])"
|
:size="((item.ui?.linkTrailingBadgeSize || props.ui?.linkTrailingBadgeSize || ui.linkTrailingBadgeSize()) as BadgeProps['size'])"
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ defineExpose({
|
|||||||
|
|
||||||
<slot name="trailing" :item="item" :index="index">
|
<slot name="trailing" :item="item" :index="index">
|
||||||
<UBadge
|
<UBadge
|
||||||
v-if="item.badge"
|
v-if="item.badge !== undefined"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
:size="((item.ui?.trailingBadgeSize || props.ui?.trailingBadgeSize || ui.trailingBadgeSize()) as BadgeProps['size'])"
|
:size="((item.ui?.trailingBadgeSize || props.ui?.trailingBadgeSize || ui.trailingBadgeSize()) as BadgeProps['size'])"
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,6 @@ 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'
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
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'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -11,12 +11,10 @@ export default {
|
|||||||
close: 'i-lucide-x',
|
close: 'i-lucide-x',
|
||||||
ellipsis: 'i-lucide-ellipsis',
|
ellipsis: 'i-lucide-ellipsis',
|
||||||
external: 'i-lucide-arrow-up-right',
|
external: 'i-lucide-arrow-up-right',
|
||||||
file: 'i-lucide-file',
|
|
||||||
folder: 'i-lucide-folder',
|
folder: 'i-lucide-folder',
|
||||||
folderOpen: 'i-lucide-folder-open',
|
folderOpen: 'i-lucide-folder-open',
|
||||||
loading: 'i-lucide-loader-circle',
|
loading: 'i-lucide-loader-circle',
|
||||||
minus: 'i-lucide-minus',
|
minus: 'i-lucide-minus',
|
||||||
plus: 'i-lucide-plus',
|
plus: 'i-lucide-plus',
|
||||||
search: 'i-lucide-search',
|
search: 'i-lucide-search'
|
||||||
upload: 'i-lucide-upload'
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ 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'
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
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