diff --git a/playground/app/pages/components/file-upload.vue b/playground/app/pages/components/file-upload.vue index c9caeada..80a035df 100644 --- a/playground/app/pages/components/file-upload.vue +++ b/playground/app/pages/components/file-upload.vue @@ -1,6 +1,9 @@ diff --git a/src/runtime/components/FileUpload.vue b/src/runtime/components/FileUpload.vue index 5b708ff1..ca491683 100644 --- a/src/runtime/components/FileUpload.vue +++ b/src/runtime/components/FileUpload.vue @@ -2,6 +2,8 @@ import type { AppConfig } from '@nuxt/schema' import type { UseFileDialogReturn } from '@vueuse/core' import theme from '#build/ui/file-upload' +import type { UseComponentIconsProps } from '../composables/useComponentIcons' +import type { ButtonProps } from '../types' import type { ComponentConfig } from '../types/utils' type FileUpload = ComponentConfig @@ -14,6 +16,19 @@ export interface FileUploadProps { 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 'md' + */ + size?: FileUpload['variants']['size'] required?: boolean disabled?: boolean multiple?: M & boolean @@ -30,7 +45,7 @@ export interface FileUploadProps { reset?: boolean /** * Create a zone that allows the user to drop files onto it. - * @defaultValue false + * @defaultValue true */ dropzone?: boolean defaultValue?: File[] @@ -47,8 +62,12 @@ export interface FileUploadSlots { open: UseFileDialogReturn['open'] reset: UseFileDialogReturn['reset'] previewUrls: string[] - files: FileList[] }): any + leading(props?: {}): any + label(props?: {}): any + description(props?: {}): any + actions(props?: {}): any + preview(props?: {}): any } @@ -58,28 +77,24 @@ 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' +import UButton from './Button.vue' defineOptions({ inheritAttrs: false }) const props = withDefaults(defineProps>(), { - accept: '*' + accept: '*', + dropzone: true }) defineEmits>() -defineSlots() +const slots = defineSlots() const modelValue = defineModel<(M extends true ? File[] : File) | null>() const appConfig = useAppConfig() as FileUpload['AppConfig'] -const { emitFormInput, emitFormChange, id, name, disabled, ariaAttrs } = useFormField(props, { deferInputValidation: true }) - -const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.fileUpload || {}) })({ - dropzone: props.dropzone -})) - const inputRef = ref() +const dropZoneRef = ref() const { files, open, reset, onCancel, onChange } = useFileDialog({ multiple: props.multiple, @@ -88,6 +103,18 @@ const { files, open, reset, onCancel, onChange } = useFileDialog({ input: inputRef.value, initialFiles: props.defaultValue }) +const { isOverDropZone } = useDropZone(dropZoneRef, { + onDrop, + dataTypes: props.accept.split(','), + multiple: props.multiple +}) +const { emitFormInput, emitFormChange, id, name, disabled, ariaAttrs } = useFormField(props, { deferInputValidation: true }) + +const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.fileUpload || {}) })({ + dropzone: props.dropzone, + multiple: props.multiple, + size: props.size +})) const previewUrls = computed(() => Array.from(files.value || []).map(file => URL.createObjectURL(file))) @@ -98,11 +125,48 @@ onChange((files) => { onCancel(() => { /** do something on cancel */ }) + +function onDrop(files: File[] | null) { + modelValue.value = (props.multiple ? files : files?.[0]) as (M extends true ? File[] : File) | null +} diff --git a/src/theme/file-upload.ts b/src/theme/file-upload.ts index 269d3c46..71951eb5 100644 --- a/src/theme/file-upload.ts +++ b/src/theme/file-upload.ts @@ -1,11 +1,72 @@ -export default { +import type { ModuleOptions } from '../module' + +export default (options: Required) => ({ slots: { - root: '', - base: '' + root: 'relative', + 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-primary', options.theme.transitions && 'transition-colors'], + 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', + preview: 'absolute inset-0' }, variants: { + color: { + ...Object.fromEntries((options.theme.colors || []).map((color: string) => [color, ''])), + neutral: '' + }, + size: { + xs: { + base: 'text-xs', + leading: 'p-1', + leadingIcon: 'size-4' + }, + sm: { + base: 'text-xs', + leading: 'p-1.5', + leadingIcon: 'size-4' + }, + md: { + base: 'text-sm', + leading: 'p-1.5', + leadingIcon: 'size-5' + }, + lg: { + base: 'text-sm', + leading: 'p-2', + leadingIcon: 'size-5' + }, + xl: { + base: 'text-base', + leading: 'p-2', + leadingIcon: 'size-6' + } + }, dropzone: { - base: 'border-dashed' + true: 'border-dashed data-[dragging=true]:bg-elevated/25' + }, + disabled: { + true: 'cursor-not-allowed opacity-75' } + }, + compoundVariants: [...(options.theme.colors || []).map((color: string) => ({ + color, + class: `has-focus-visible:ring-2 has-focus-visible:ring-inset has-focus-visible:ring-${color}` + })), ...(options.theme.colors || []).map((color: string) => ({ + color, + highlight: true, + class: `ring ring-inset ring-${color}` + })), { + color: 'neutral', + class: 'has-focus-visible:ring-2 has-focus-visible:ring-inset has-focus-visible:ring-inverted' + }, { + color: 'neutral', + highlight: true, + class: 'ring ring-inset ring-inverted' + }], + defaultVariants: { + size: 'md' } -} +}) diff --git a/src/theme/icons.ts b/src/theme/icons.ts index b0d6357b..bee424c1 100644 --- a/src/theme/icons.ts +++ b/src/theme/icons.ts @@ -16,5 +16,6 @@ export default { 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' }