diff --git a/docs/app/components/content/examples/file-upload/FileUploadExtendTypeExample.vue b/docs/app/components/content/examples/file-upload/FileUploadExtendTypeExample.vue new file mode 100644 index 0000000000..96b6c24e5d --- /dev/null +++ b/docs/app/components/content/examples/file-upload/FileUploadExtendTypeExample.vue @@ -0,0 +1,14 @@ + + + + + diff --git a/docs/app/components/content/examples/file-upload/FileUploadFileValidationExample.vue b/docs/app/components/content/examples/file-upload/FileUploadFileValidationExample.vue new file mode 100644 index 0000000000..9289910d52 --- /dev/null +++ b/docs/app/components/content/examples/file-upload/FileUploadFileValidationExample.vue @@ -0,0 +1,86 @@ + + + + + + + + + + + + {{ text }}. Must satisfy: + + + + + + {{ req.text }} + + {{ req.met ? ' - Requirement met' : ' - Requirement not met' }} + + + + + + diff --git a/docs/app/components/content/examples/file-upload/FileUploadFormFieldExample.vue b/docs/app/components/content/examples/file-upload/FileUploadFormFieldExample.vue new file mode 100644 index 0000000000..143a296b08 --- /dev/null +++ b/docs/app/components/content/examples/file-upload/FileUploadFormFieldExample.vue @@ -0,0 +1,19 @@ + + + + + + + diff --git a/docs/content/3.components/file-upload.md b/docs/content/3.components/file-upload.md new file mode 100644 index 0000000000..1eb4288707 --- /dev/null +++ b/docs/content/3.components/file-upload.md @@ -0,0 +1,248 @@ +--- +title: FileUpload +description: A drag-and-drop file upload component. +category: form +links: + - label: GitHub + icon: i-simple-icons-github + to: https://github.com/nuxt/ui/tree/v3/src/runtime/components/FileUpload.vue +navigation.badge: New +--- + +## Usage + +Use the `v-model` directive to control the value of the Input. + +::component-code +--- +ignore: + - modelValue +external: + - modelValue +externalTypes: + - FileUploadItem[] +props: + modelValue: [] +--- +:: + +### Accept + +Use the `accept` prop to specify the types of files that can be uploaded. + +::component-code +--- +ignore: + - modelValue +external: + - modelValue +externalTypes: + - FileUploadItem[] +props: + modelValue: [] + accept: 'image/*' +--- +:: + +### Label + +Use the `label` prop to set the label of the component. + +::component-code +--- +ignore: + - modelValue +external: + - modelValue +externalTypes: + - FileUploadItem[] +props: + modelValue: [] + label: 'Upload your image' +--- +:: + +### Upload Icon + +Use the `uploadIcon` prop to set a custom upload icon. + +::component-code +--- +ignore: + - modelValue +external: + - modelValue +externalTypes: + - FileUploadItem[] +props: + modelValue: [] + uploadIcon: 'i-heroicons-cloud-arrow-up-solid' +--- +:: + +::framework-only +#nuxt +:::tip{to="/getting-started/icons/nuxt#theme"} +You can customize this icon globally in your `app.config.ts` under `ui.icons.upload` key. +::: + +#vue +:::tip{to="/getting-started/icons/vue#theme"} +You can customize this icon globally in your `vite.config.ts` under `ui.icons.upload` key. +::: +:: + +### Size + +Use the `size` prop to set the size of the component. + +::component-code +--- +ignore: + - modelValue +external: + - modelValue +externalTypes: + - FileUploadItem[] +props: + modelValue: [] + size: xl +--- +:: + +### Multiple + +Use the `multiple` prop to allow multiple file uploads. + +::component-code +--- +ignore: + - modelValue + - multiple +external: + - modelValue +externalTypes: + - FileUploadItem[] +props: + modelValue: [] + multiple: true +--- +:: + +### File Icon + +Use the `fileIcon` prop to set a custom file icon. + +::component-code +--- +ignore: + - modelValue + - accept +external: + - modelValue +externalTypes: + - FileUploadItem[] +props: + modelValue: + - file: + name: 'example.txt' + size: 3145728 + type: 'text/plain' + fileIcon: 'i-heroicons-document-text-solid' + accept: 'text/plain' +--- +:: + +::framework-only +#nuxt +:::tip{to="/getting-started/icons/nuxt#theme"} +You can customize this icon globally in your `app.config.ts` under `ui.icons.file` key. +::: + +#vue +:::tip{to="/getting-started/icons/vue#theme"} +You can customize this icon globally in your `vite.config.ts` under `ui.icons.file` key. +::: +:: + +### Disabled + +Use the `disabled` prop to disable the component. + +::component-code +--- +ignore: + - modelValue +external: + - modelValue +externalTypes: + - FileUploadItem[] +props: + modelValue: [] + disabled: true +--- +:: + +## Examples + +### With custom type + +You can extend the type to include additional properties. + +::component-example +--- +name: 'file-upload-extend-type-example' +--- +:: + +### Within a FormField + +You can use the FileUpload within a [FormField](/components/form-field) component to display a label, help text, required indicator, etc. + +::component-example +--- +name: 'file-upload-form-field-example' +--- +:: + +::tip{to="/components/form"} +It also provides validation and error handling when used within a **Form** component. +:: + +### With file validation + +You can build a custom validation function to check the file type and size. + +::component-example +--- +collapse: true +name: 'file-upload-file-validation-example' +--- +:: + + +## API + +### Props + +:component-props + +### Slots + +:component-slots + +### Emits + +:component-emits + +### Expose + +When accessing the component via a template ref, you can use the following: + +| Name | Type | +| ---- | ---- | +| `fileInputRef`{lang="ts-type"} | `Ref`{lang="ts-type"} | + +## Theme + +:component-theme diff --git a/playground-vue/src/app.vue b/playground-vue/src/app.vue index 62f0573b83..899c4f396b 100644 --- a/playground-vue/src/app.vue +++ b/playground-vue/src/app.vue @@ -37,6 +37,7 @@ const components = [ 'dropdown-menu', 'form', 'form-field', + 'file-upload', 'input', 'input-menu', 'input-number', diff --git a/playground/app/app.vue b/playground/app/app.vue index d547b989ed..9c240b05f1 100644 --- a/playground/app/app.vue +++ b/playground/app/app.vue @@ -37,6 +37,7 @@ const components = [ 'dropdown-menu', 'form', 'form-field', + 'file-upload', 'input', 'input-menu', 'input-number', diff --git a/playground/app/pages/components/file-upload.vue b/playground/app/pages/components/file-upload.vue new file mode 100644 index 0000000000..913208d049 --- /dev/null +++ b/playground/app/pages/components/file-upload.vue @@ -0,0 +1,24 @@ + + + + + + + + + + + + + diff --git a/src/runtime/components/FileUpload.vue b/src/runtime/components/FileUpload.vue new file mode 100644 index 0000000000..909ae1b08f --- /dev/null +++ b/src/runtime/components/FileUpload.vue @@ -0,0 +1,424 @@ + + + + + + + + + + + {{ `${t('fileUpload.actions')} (${files?.length || 0})` }} + + + + + + + + + + + + + + + + + {{ item.file.name }} + + + + {{ (item.file.size / 1024 / 1024).toFixed(2) }} MB + + + + + + + + + + + + + + + + + + + + + + + + + + {{ label || t("fileUpload.empty") }} + + + + + + + + + + + + diff --git a/src/runtime/locale/ar.ts b/src/runtime/locale/ar.ts index 0c7de046b9..7b6f5f2227 100644 --- a/src/runtime/locale/ar.ts +++ b/src/runtime/locale/ar.ts @@ -21,6 +21,12 @@ export default defineLocale({ increment: 'زيادة', decrement: 'تقليل' }, + fileUpload: { + empty: 'استعرض أو اسحب الملفات هنا', + removeAll: 'إزالة الكل', + addFiles: 'إضافة ملف(ات)', + actions: 'الملفات' + }, commandPalette: { placeholder: 'اكتب أمرًا أو ابحث...', noMatch: 'لا توجد نتائج مطابقة', diff --git a/src/runtime/locale/az.ts b/src/runtime/locale/az.ts index baeca3ba25..a9c402d081 100644 --- a/src/runtime/locale/az.ts +++ b/src/runtime/locale/az.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Artır', decrement: 'Azalt' }, + fileUpload: { + empty: 'Faylları buraya sürükləyin və ya seçin', + removeAll: 'Hamısını sil', + addFiles: 'Fayl(lar) əlavə et', + actions: 'Fayllar' + }, commandPalette: { placeholder: 'Əmr daxil edin və ya axtarın...', noMatch: 'Uyğun məlumat tapılmadı', diff --git a/src/runtime/locale/bg.ts b/src/runtime/locale/bg.ts index 9282ed294b..92b28b2a4e 100644 --- a/src/runtime/locale/bg.ts +++ b/src/runtime/locale/bg.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Увеличаване', decrement: 'Намаляване' }, + fileUpload: { + empty: 'Пераглядаць або перацягнуць файлы сюды', + removeAll: 'Выдаліць усё', + addFiles: 'Дадаць файл(ы)', + actions: 'Файлы' + }, commandPalette: { placeholder: 'Въведете команда или потърсете...', noMatch: 'Няма съвпадение на данни', diff --git a/src/runtime/locale/bn.ts b/src/runtime/locale/bn.ts index a4abeecdf9..349e89874a 100644 --- a/src/runtime/locale/bn.ts +++ b/src/runtime/locale/bn.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'বৃদ্ধি করুন', decrement: 'হ্রাস করুন' }, + fileUpload: { + empty: 'ব্রাউজ করুন বা ফাইলগুলি এখানে ড্রপ করুন', + removeAll: 'সব সরান', + addFiles: 'ফাইল যোগ করুন', + actions: 'ফাইলসমূহ' + }, commandPalette: { placeholder: 'কমান্ড টাইপ করুন বা অনুসন্ধান করুন...', noMatch: 'কোন মিল পাওয়া যায়নি', diff --git a/src/runtime/locale/ca.ts b/src/runtime/locale/ca.ts index 7660bca021..6ae39aa567 100644 --- a/src/runtime/locale/ca.ts +++ b/src/runtime/locale/ca.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Incrementar', decrement: 'Decrementar' }, + fileUpload: { + empty: 'Navegueu o arrossegueu fitxers aquí', + removeAll: 'Eliminar tot', + addFiles: 'Afegir fitxer(s)', + actions: 'Fitxers' + }, commandPalette: { placeholder: 'Escriu una ordre o cerca...', noMatch: 'No hi ha dades coincidents', diff --git a/src/runtime/locale/ckb.ts b/src/runtime/locale/ckb.ts index d4ade9688d..d5f6a9c842 100644 --- a/src/runtime/locale/ckb.ts +++ b/src/runtime/locale/ckb.ts @@ -21,6 +21,12 @@ export default defineLocale({ increment: 'زیادکردن', decrement: 'کەمکردنەوە' }, + fileUpload: { + empty: 'گۆڕانکاری یان فایلەکان بخەرە ئێرە', + removeAll: 'هەموویان لابدە', + addFiles: 'فایل زیاد بکە', + actions: 'فایلەکان' + }, commandPalette: { placeholder: 'فەرمانێک بنووسە یان بگەڕێ...', noMatch: 'هیچ ئەنجامێک نەدۆزرایەوە', diff --git a/src/runtime/locale/cs.ts b/src/runtime/locale/cs.ts index 8a911d0e28..5e678307a5 100644 --- a/src/runtime/locale/cs.ts +++ b/src/runtime/locale/cs.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Zvýšit', decrement: 'Snížit' }, + fileUpload: { + empty: 'Procházet nebo přetáhnout soubory zde', + removeAll: 'Odstranit vše', + addFiles: 'Přidat soubor(y)', + actions: 'Soubory' + }, commandPalette: { placeholder: 'Zadejte příkaz nebo hledejte...', noMatch: 'Žádná shoda', diff --git a/src/runtime/locale/da.ts b/src/runtime/locale/da.ts index 7a67a020c9..0d6ac73d6e 100644 --- a/src/runtime/locale/da.ts +++ b/src/runtime/locale/da.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Øg', decrement: 'Reducer' }, + fileUpload: { + empty: 'Gennemse eller træk filer hertil', + removeAll: 'Fjern alle', + addFiles: 'Tilføj fil(er)', + actions: 'Filer' + }, commandPalette: { placeholder: 'Skriv en kommando eller søg...', noMatch: 'Ingen matchende data', diff --git a/src/runtime/locale/de.ts b/src/runtime/locale/de.ts index 4664e6fca8..04c5d702e8 100644 --- a/src/runtime/locale/de.ts +++ b/src/runtime/locale/de.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Erhöhen', decrement: 'Verringern' }, + fileUpload: { + empty: 'Dateien durchsuchen oder hier ablegen', + removeAll: 'Alle entfernen', + addFiles: 'Datei(en) hinzufügen', + actions: 'Dateien' + }, commandPalette: { placeholder: 'Geben Sie einen Befehl ein oder suchen Sie...', noMatch: 'Nichts gefunden', diff --git a/src/runtime/locale/el.ts b/src/runtime/locale/el.ts index 8cc526abf2..342753ab14 100644 --- a/src/runtime/locale/el.ts +++ b/src/runtime/locale/el.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Αύξηση', decrement: 'Μείωση' }, + fileUpload: { + empty: 'Περιηγηθείτε ή σύρετε αρχεία εδώ', + removeAll: 'Αφαίρεση όλων', + addFiles: 'Προσθήκη αρχείου(ων)', + actions: 'Αρχεία' + }, commandPalette: { placeholder: 'Πληκτρολογήστε μια εντολή ή αναζητήστε...', noMatch: 'Δεν βρέθηκαν δεδομένα', diff --git a/src/runtime/locale/en.ts b/src/runtime/locale/en.ts index dc35884cf1..89c96a9343 100644 --- a/src/runtime/locale/en.ts +++ b/src/runtime/locale/en.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Increment', decrement: 'Decrement' }, + fileUpload: { + empty: 'Browse or drop files here', + removeAll: 'Remove all', + addFiles: 'Add file(s)', + actions: 'Files' + }, commandPalette: { placeholder: 'Type a command or search...', noMatch: 'No matching data', diff --git a/src/runtime/locale/es.ts b/src/runtime/locale/es.ts index 31f8ddb6e9..2f393c5313 100644 --- a/src/runtime/locale/es.ts +++ b/src/runtime/locale/es.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Incremento', decrement: 'Decremento' }, + fileUpload: { + empty: 'Explorar o arrastrar archivos aquí', + removeAll: 'Eliminar todo', + addFiles: 'Añadir archivo(s)', + actions: 'Archivos' + }, commandPalette: { placeholder: 'Escribe un comando o busca...', noMatch: 'No hay datos coincidentes', diff --git a/src/runtime/locale/et.ts b/src/runtime/locale/et.ts index 7fd252177d..2c2271475a 100644 --- a/src/runtime/locale/et.ts +++ b/src/runtime/locale/et.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Suurenda', decrement: 'Vähenda' }, + fileUpload: { + empty: 'Sirvige või lohistage failid siia', + removeAll: 'Eemalda kõik', + addFiles: 'Lisa fail(id)', + actions: 'Failid' + }, commandPalette: { placeholder: 'Sisesta käsk või otsi...', noMatch: 'Pole vastavaid andmeid', diff --git a/src/runtime/locale/fa_ir.ts b/src/runtime/locale/fa_ir.ts index e932640793..58f471ec82 100644 --- a/src/runtime/locale/fa_ir.ts +++ b/src/runtime/locale/fa_ir.ts @@ -21,6 +21,12 @@ export default defineLocale({ increment: 'افزایش', decrement: 'کاهش' }, + fileUpload: { + empty: 'فایلها را مرور کنید یا اینجا بکشید', + removeAll: 'حذف همه', + addFiles: 'افزودن فایل', + actions: 'فایلها' + }, commandPalette: { placeholder: 'یک دستور وارد کنید یا جستجو کنید...', noMatch: 'دادهای یافت نشد', diff --git a/src/runtime/locale/fi.ts b/src/runtime/locale/fi.ts index 6588424d30..c4a9603ce7 100644 --- a/src/runtime/locale/fi.ts +++ b/src/runtime/locale/fi.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Kasvata', decrement: 'Vähennä' }, + fileUpload: { + empty: 'Selaa tai pudota tiedostoja tähän', + removeAll: 'Poista kaikki', + addFiles: 'Lisää tiedosto(ja)', + actions: 'Tiedostot' + }, commandPalette: { placeholder: 'Kirjoita komento tai hae...', noMatch: 'Ei vastaavia tietoja', diff --git a/src/runtime/locale/fr.ts b/src/runtime/locale/fr.ts index e823efc3ad..462bb8dcec 100644 --- a/src/runtime/locale/fr.ts +++ b/src/runtime/locale/fr.ts @@ -16,6 +16,12 @@ export default defineLocale({ prevMonth: 'Mois précédent', nextMonth: 'Mois suivant' }, + fileUpload: { + empty: 'Parcourir ou déposer des fichiers ici', + removeAll: 'Supprimer tout', + addFiles: 'Ajouter fichier(s)', + actions: 'Fichiers' + }, inputNumber: { increment: 'Augmenter', decrement: 'Diminuer' diff --git a/src/runtime/locale/he.ts b/src/runtime/locale/he.ts index dc305dcde8..6ab5adb5a8 100644 --- a/src/runtime/locale/he.ts +++ b/src/runtime/locale/he.ts @@ -21,6 +21,12 @@ export default defineLocale({ increment: 'הוסף', decrement: 'הפחת' }, + fileUpload: { + empty: 'עיון או גרירת קבצים כאן', + removeAll: 'הסר הכל', + addFiles: 'הוסף קובץ/קבצים', + actions: 'קבצים' + }, commandPalette: { placeholder: 'הקלד פקודה...', noMatch: 'לא נמצאה התאמה', diff --git a/src/runtime/locale/hi.ts b/src/runtime/locale/hi.ts index 501cde1428..93f23b38e1 100644 --- a/src/runtime/locale/hi.ts +++ b/src/runtime/locale/hi.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'बढ़ाना', decrement: 'घटाना' }, + fileUpload: { + empty: 'फाइल ब्राउज़ करें या यहाँ ड्रॉप करें', + removeAll: 'सभी हटाएँ', + addFiles: 'फाइल जोड़ें', + actions: 'फाइलें' + }, commandPalette: { placeholder: 'एक आदेश या खोज टाइप करें...', noMatch: 'कोई मेल खाता डेटा नहीं', diff --git a/src/runtime/locale/hu.ts b/src/runtime/locale/hu.ts index d7e05fc57c..1a4adf3e2b 100644 --- a/src/runtime/locale/hu.ts +++ b/src/runtime/locale/hu.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Növel', decrement: 'Csökkent' }, + fileUpload: { + empty: 'Böngésszen vagy húzza ide a fájlokat', + removeAll: 'Összes eltávolítása', + addFiles: 'Fájl(ok) hozzáadása', + actions: 'Fájlok' + }, commandPalette: { placeholder: 'Írjon be egy parancsot vagy keressen...', noMatch: 'Nincs találat', diff --git a/src/runtime/locale/hy.ts b/src/runtime/locale/hy.ts index 591d1f1f6f..4eec7e5c03 100644 --- a/src/runtime/locale/hy.ts +++ b/src/runtime/locale/hy.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Ավելացնել', decrement: 'Պակասեցնել' }, + fileUpload: { + empty: 'Հայտնաբերել կամ քաշել ֆայլերը այստեղ', + removeAll: 'Հեռացնել բոլորը', + addFiles: 'Ավելացնել ֆայլ(եր)', + actions: 'Ֆայլեր' + }, commandPalette: { placeholder: 'Մուտքագրեք հրաման կամ որոնեք...', noMatch: 'Համընկնումներ չեն գտնվել', diff --git a/src/runtime/locale/id.ts b/src/runtime/locale/id.ts index f9a212d201..ef0d737a4e 100644 --- a/src/runtime/locale/id.ts +++ b/src/runtime/locale/id.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Tambah', decrement: 'Kurangi' }, + fileUpload: { + empty: 'Telusuri atau seret file ke sini', + removeAll: 'Hapus semua', + addFiles: 'Tambah file', + actions: 'File' + }, commandPalette: { placeholder: 'Ketik perintah atau cari...', noMatch: 'Tidak ada data yang cocok', diff --git a/src/runtime/locale/it.ts b/src/runtime/locale/it.ts index afc2ae65a9..9515949401 100644 --- a/src/runtime/locale/it.ts +++ b/src/runtime/locale/it.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Aumenta', decrement: 'Diminuisci' }, + fileUpload: { + empty: 'Sfoglia o trascina i file qui', + removeAll: 'Rimuovi tutto', + addFiles: 'Aggiungi file', + actions: 'File' + }, commandPalette: { placeholder: 'Digita un comando o cerca...', noMatch: 'Nessun dato corrispondente', diff --git a/src/runtime/locale/ja.ts b/src/runtime/locale/ja.ts index 771674ead6..2686bc3cb8 100644 --- a/src/runtime/locale/ja.ts +++ b/src/runtime/locale/ja.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: '増やす', decrement: '減らす' }, + fileUpload: { + empty: 'ファイルを参照またはここにドロップ', + removeAll: 'すべて削除', + addFiles: 'ファイルを追加', + actions: 'ファイル' + }, commandPalette: { placeholder: 'コマンドを入力するか検索...', noMatch: '一致するデータがありません', diff --git a/src/runtime/locale/kk.ts b/src/runtime/locale/kk.ts index fd1dcbd793..81133a888c 100644 --- a/src/runtime/locale/kk.ts +++ b/src/runtime/locale/kk.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Арттыру', decrement: 'Азайту' }, + fileUpload: { + empty: 'Файлдарды шолыңыз немесе осы жерге тастаңыз', + removeAll: 'Барлығын жою', + addFiles: 'Файл(дар) қосу', + actions: 'Файлдар' + }, commandPalette: { placeholder: 'Команда енгізіңіз немесе іздеңіз...', noMatch: 'Сәйкес келетін деректер жоқ', diff --git a/src/runtime/locale/km.ts b/src/runtime/locale/km.ts index 6810ba46d7..a518d23ee9 100644 --- a/src/runtime/locale/km.ts +++ b/src/runtime/locale/km.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'បង្កើន', decrement: 'បន្ថយ' }, + fileUpload: { + empty: 'រកមើល ឬទាញឯកសារមកទីនេះ', + removeAll: 'លុបទាំងអស់', + addFiles: 'បន្ថែមឯកសារ', + actions: 'ឯកសារ' + }, commandPalette: { placeholder: 'វាយពាក្យបញ្ជា ឬស្វែងរក...', noMatch: 'មិនមានទិន្នន័យដែលត្រូវគ្នាទេ', diff --git a/src/runtime/locale/ko.ts b/src/runtime/locale/ko.ts index 54b872d2d3..9c09e802d4 100644 --- a/src/runtime/locale/ko.ts +++ b/src/runtime/locale/ko.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: '증가', decrement: '감소' }, + fileUpload: { + empty: '파일을 선택하거나 여기에 드롭하세요', + removeAll: '모두 제거', + addFiles: '파일 추가', + actions: '파일' + }, commandPalette: { placeholder: '명령을 입력하거나 검색...', noMatch: '일치하는 데이터가 없습니다.', diff --git a/src/runtime/locale/ky.ts b/src/runtime/locale/ky.ts index 191de3e656..f1048fa62b 100644 --- a/src/runtime/locale/ky.ts +++ b/src/runtime/locale/ky.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Кошуу', decrement: 'Азайтуу' }, + fileUpload: { + empty: 'Файлдарды көрүңүз же бул жерге таштаңыз', + removeAll: 'Баарын өчүрүү', + addFiles: 'Файл(дар) кошуу', + actions: 'Файлдар' + }, commandPalette: { placeholder: 'Буйрук киргизиңиз же издөө…', noMatch: 'Эч нерсе табылган жок', diff --git a/src/runtime/locale/lb.ts b/src/runtime/locale/lb.ts index 59eccf3089..3fedfef573 100644 --- a/src/runtime/locale/lb.ts +++ b/src/runtime/locale/lb.ts @@ -16,6 +16,12 @@ export default defineLocale({ prevMonth: 'Virege Mount', nextMonth: 'Nächste Mount' }, + fileUpload: { + empty: 'Fir duerch Fichieren oder drop se hei', + removeAll: 'All läschen', + addFiles: 'Fichier(en) dobäisetzen', + actions: 'Fichieren' + }, inputNumber: { increment: 'Inkrementéieren', decrement: 'Dekrementéieren' diff --git a/src/runtime/locale/lt.ts b/src/runtime/locale/lt.ts index 3be6918a02..bbb5520d59 100644 --- a/src/runtime/locale/lt.ts +++ b/src/runtime/locale/lt.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Padidinti', decrement: 'Sumažinti' }, + fileUpload: { + empty: 'Naršykite arba nuvilkite failus čia', + removeAll: 'Pašalinti visus', + addFiles: 'Pridėti failą(us)', + actions: 'Failai' + }, commandPalette: { placeholder: 'Įveskite komandą arba ieškokite...', noMatch: 'Nėra atitinkančių duomenų', diff --git a/src/runtime/locale/mn.ts b/src/runtime/locale/mn.ts index 87bd7dc5e4..513fc68669 100644 --- a/src/runtime/locale/mn.ts +++ b/src/runtime/locale/mn.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Нэмэх', decrement: 'Хасах' }, + fileUpload: { + empty: 'Файлуудыг хайх эсвэл энд тавина уу', + removeAll: 'Бүгдийг устгах', + addFiles: 'Файл нэмэх', + actions: 'Файлууд' + }, commandPalette: { placeholder: 'Комманд бичих эсвэл хайлт хийх...', noMatch: 'Тохирох мэдээлэл олдсонгүй', diff --git a/src/runtime/locale/ms.ts b/src/runtime/locale/ms.ts index 668ff3839f..65275ad60c 100644 --- a/src/runtime/locale/ms.ts +++ b/src/runtime/locale/ms.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Naikkan', decrement: 'Kurangkan' }, + fileUpload: { + empty: 'Layari atau seret fail ke sini', + removeAll: 'Buang semua', + addFiles: 'Tambah fail', + actions: 'Fail' + }, commandPalette: { placeholder: 'Taip arahan atau carian...', noMatch: 'Tiada data yang sepadan', diff --git a/src/runtime/locale/nb_no.ts b/src/runtime/locale/nb_no.ts index 634b1cf050..214dfc946f 100644 --- a/src/runtime/locale/nb_no.ts +++ b/src/runtime/locale/nb_no.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Øk', decrement: 'Reduser' }, + fileUpload: { + empty: 'Bla gjennom eller dra filer hit', + removeAll: 'Fjern alle', + addFiles: 'Legg til fil(er)', + actions: 'Filer' + }, commandPalette: { placeholder: 'Skriv inn en kommando eller søk...', noMatch: 'Ingen samsvarende data', diff --git a/src/runtime/locale/nl.ts b/src/runtime/locale/nl.ts index 6f116e829b..3f4250d88b 100644 --- a/src/runtime/locale/nl.ts +++ b/src/runtime/locale/nl.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Verhogen', decrement: 'Verlagen' }, + fileUpload: { + empty: 'Bladeren of bestanden hier neerzetten', + removeAll: 'Alles verwijderen', + addFiles: 'Bestand(en) toevoegen', + actions: 'Bestanden' + }, commandPalette: { placeholder: 'Typ een commando of zoek...', noMatch: 'Geen overeenkomende gegevens', diff --git a/src/runtime/locale/pl.ts b/src/runtime/locale/pl.ts index 2fec21365d..c2202f412e 100644 --- a/src/runtime/locale/pl.ts +++ b/src/runtime/locale/pl.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Zwiększ', decrement: 'Zmniejsz' }, + fileUpload: { + empty: 'Przeglądaj lub przeciągnij pliki tutaj', + removeAll: 'Usuń wszystkie', + addFiles: 'Dodaj plik(i)', + actions: 'Pliki' + }, commandPalette: { placeholder: 'Wpisz polecenie lub wyszukaj...', noMatch: 'Brak pasujących danych', diff --git a/src/runtime/locale/pt.ts b/src/runtime/locale/pt.ts index fb5e72f3d7..7e8ab7c5a5 100644 --- a/src/runtime/locale/pt.ts +++ b/src/runtime/locale/pt.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Incrementar', decrement: 'Decrementar' }, + fileUpload: { + empty: 'Procurar ou arrastar arquivos aqui', + removeAll: 'Remover todos', + addFiles: 'Adicionar arquivo(s)', + actions: 'Arquivos' + }, commandPalette: { placeholder: 'Digite um comando ou pesquise...', noMatch: 'Nenhum dado correspondente', diff --git a/src/runtime/locale/pt_br.ts b/src/runtime/locale/pt_br.ts index 052bae6a5e..c6d37c863c 100644 --- a/src/runtime/locale/pt_br.ts +++ b/src/runtime/locale/pt_br.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Incrementar', decrement: 'Decrementar' }, + fileUpload: { + empty: 'Procurar ou arrastar arquivos aqui', + removeAll: 'Remover todos', + addFiles: 'Adicionar arquivo(s)', + actions: 'Arquivos' + }, commandPalette: { placeholder: 'Digite um comando ou pesquise...', noMatch: 'Nenhum dado correspondente', diff --git a/src/runtime/locale/ro.ts b/src/runtime/locale/ro.ts index dfd9b6a011..10a10a10de 100644 --- a/src/runtime/locale/ro.ts +++ b/src/runtime/locale/ro.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Crește', decrement: 'Scade' }, + fileUpload: { + empty: 'Răsfoiți sau trageți fișierele aici', + removeAll: 'Eliminați toate', + addFiles: 'Adăugați fișier(e)', + actions: 'Fișiere' + }, commandPalette: { placeholder: 'Tastează o comandă sau caută...', noMatch: 'Nu există date corespunzătoare', diff --git a/src/runtime/locale/ru.ts b/src/runtime/locale/ru.ts index 2df96842ea..624f03afe0 100644 --- a/src/runtime/locale/ru.ts +++ b/src/runtime/locale/ru.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Увеличить', decrement: 'Уменьшить' }, + fileUpload: { + empty: 'Просматривать или перетащить файлы сюда', + removeAll: 'Удалить все', + addFiles: 'Добавить файл(ы)', + actions: 'Файлы' + }, commandPalette: { placeholder: 'Введите команду или выполните поиск...', noMatch: 'Совпадений не найдено', diff --git a/src/runtime/locale/sk.ts b/src/runtime/locale/sk.ts index c57bba0ad8..b0a34eb2b6 100644 --- a/src/runtime/locale/sk.ts +++ b/src/runtime/locale/sk.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Zvýšiť', decrement: 'Znížiť' }, + fileUpload: { + empty: 'Prehliadať alebo presunúť súbory sem', + removeAll: 'Odstrániť všetko', + addFiles: 'Pridať súbor(y)', + actions: 'Súbory' + }, commandPalette: { placeholder: 'Zadajte príkaz alebo vyhľadajte...', noMatch: 'Žiadna zhoda', diff --git a/src/runtime/locale/sl.ts b/src/runtime/locale/sl.ts index 9df276955c..e9ffda0204 100644 --- a/src/runtime/locale/sl.ts +++ b/src/runtime/locale/sl.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Povišaj', decrement: 'Zmanjšaj' }, + fileUpload: { + empty: 'Prebrskajte ali povlecite datoteke sem', + removeAll: 'Odstrani vse', + addFiles: 'Dodaj datoteko(e)', + actions: 'Datoteke' + }, commandPalette: { placeholder: 'Vpiši ukaz ali išči...', noMatch: 'Ni ujemanj', diff --git a/src/runtime/locale/sv.ts b/src/runtime/locale/sv.ts index 3a66ccd821..4db812f9e9 100644 --- a/src/runtime/locale/sv.ts +++ b/src/runtime/locale/sv.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Öka', decrement: 'Minska' }, + fileUpload: { + empty: 'Bläddra eller dra filer hit', + removeAll: 'Ta bort alla', + addFiles: 'Lägg till fil(er)', + actions: 'Filer' + }, commandPalette: { placeholder: 'Skriv ett kommando eller sök...', noMatch: 'Inga matchande data', diff --git a/src/runtime/locale/th.ts b/src/runtime/locale/th.ts index 50f258b201..db78219975 100644 --- a/src/runtime/locale/th.ts +++ b/src/runtime/locale/th.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'เพิ่ม', decrement: 'ลด' }, + fileUpload: { + empty: 'เรียกดูหรือลากไฟล์มาที่นี่', + removeAll: 'ลบทั้งหมด', + addFiles: 'เพิ่มไฟล์', + actions: 'ไฟล์' + }, commandPalette: { placeholder: 'พิมพ์คำสั่งหรือค้นหา...', noMatch: 'ไม่พบข้อมูลที่ตรงกัน', diff --git a/src/runtime/locale/tj.ts b/src/runtime/locale/tj.ts index 5fd02adf27..af1754597e 100644 --- a/src/runtime/locale/tj.ts +++ b/src/runtime/locale/tj.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Зиёд кардан', decrement: 'Кам кардан' }, + fileUpload: { + empty: 'Файлҳоро кушед ё ба ин ҷо кашед', + removeAll: 'Ҳамаро нест кардан', + addFiles: 'Файл(ҳо) илова кардан', + actions: 'Файлҳо' + }, commandPalette: { placeholder: 'Фармонро нависед ё ҷустуҷӯ кунед...', noMatch: 'Маълумоти мувофиқ ёфт нашуд', diff --git a/src/runtime/locale/tr.ts b/src/runtime/locale/tr.ts index e57ed3cdc8..324fc16cf3 100644 --- a/src/runtime/locale/tr.ts +++ b/src/runtime/locale/tr.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Arttır', decrement: 'Azalt' }, + fileUpload: { + empty: 'Dosyalara göz atın veya buraya sürükleyin', + removeAll: 'Tümünü kaldır', + addFiles: 'Dosya(lar) ekle', + actions: 'Dosyalar' + }, commandPalette: { placeholder: 'Bir komut yazın veya arama yapın...', noMatch: 'Eşleşen veri yok', diff --git a/src/runtime/locale/ug_cn.ts b/src/runtime/locale/ug_cn.ts index 0f50b157e6..8a2b41538e 100644 --- a/src/runtime/locale/ug_cn.ts +++ b/src/runtime/locale/ug_cn.ts @@ -21,6 +21,12 @@ export default defineLocale({ increment: 'كۆپەيتىش', decrement: 'ئازايتىش' }, + fileUpload: { + empty: 'ھۆججەتلەرنى كۆرۈڭ ياكى بۇ يەرگە سۆرەڭ', + removeAll: 'ھەممىنى چىقىرىۋەت', + addFiles: 'ھۆججەت قوش', + actions: 'ھۆججەتلەر' + }, commandPalette: { placeholder: 'بۇيرۇق كىرگۈزۈڭ ياكى ئىزدەڭ...', noMatch: 'ماس كېلىدىغان سانلىق مەلۇمات يوق', diff --git a/src/runtime/locale/uk.ts b/src/runtime/locale/uk.ts index 45a3c1d4d5..67994281de 100644 --- a/src/runtime/locale/uk.ts +++ b/src/runtime/locale/uk.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Збільшити', decrement: 'Зменшити' }, + fileUpload: { + empty: 'Переглядати або перетягнути файли сюди', + removeAll: 'Видалити все', + addFiles: 'Додати файл(и)', + actions: 'Файли' + }, commandPalette: { placeholder: 'Введіть команду або шукайте...', noMatch: 'Збігів не знайдено', diff --git a/src/runtime/locale/ur.ts b/src/runtime/locale/ur.ts index f001f4b5df..54d7a33f41 100644 --- a/src/runtime/locale/ur.ts +++ b/src/runtime/locale/ur.ts @@ -21,6 +21,12 @@ export default defineLocale({ increment: 'اضافہ', decrement: 'کمی' }, + fileUpload: { + empty: 'فائلیں براؤز کریں یا یہاں ڈراپ کریں', + removeAll: 'سب ہٹائیں', + addFiles: 'فائل(یں) شامل کریں', + actions: 'فائلیں' + }, commandPalette: { placeholder: 'کمانڈ ٹائپ کریں یا تلاش کریں...', noMatch: 'کوئی ملتا جلتا ڈیٹا نہیں ملا', diff --git a/src/runtime/locale/uz.ts b/src/runtime/locale/uz.ts index 4b939ee3bb..5cff664cd8 100644 --- a/src/runtime/locale/uz.ts +++ b/src/runtime/locale/uz.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Qoʻshish', decrement: 'Ayirish' }, + fileUpload: { + empty: 'Fayllarni ko\'rib chiqish yoki shu yerga tashlash', + removeAll: 'Barchasini o\'chirish', + addFiles: 'Fayl(lar) qo\'shish', + actions: 'Fayllar' + }, commandPalette: { placeholder: 'Buyruq kiriting yoki qidiring...', noMatch: 'Mos keluvchi natija topilmadi', diff --git a/src/runtime/locale/vi.ts b/src/runtime/locale/vi.ts index ee53d8585c..56bb8a2661 100644 --- a/src/runtime/locale/vi.ts +++ b/src/runtime/locale/vi.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: 'Tăng', decrement: 'Giảm' }, + fileUpload: { + empty: 'Duyệt hoặc kéo file vào đây', + removeAll: 'Xóa tất cả', + addFiles: 'Thêm file', + actions: 'File' + }, commandPalette: { placeholder: 'Nhập lệnh hoặc tìm kiếm...', noMatch: 'Không có kết quả phù hợp', diff --git a/src/runtime/locale/zh_cn.ts b/src/runtime/locale/zh_cn.ts index 4b67a3596f..b08d07ea9e 100644 --- a/src/runtime/locale/zh_cn.ts +++ b/src/runtime/locale/zh_cn.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: '增加', decrement: '减少' }, + fileUpload: { + empty: '浏览或拖拽文件到此处', + removeAll: '移除全部', + addFiles: '添加文件', + actions: '文件' + }, commandPalette: { placeholder: '输入命令或搜索...', noMatch: '没有匹配的数据', diff --git a/src/runtime/locale/zh_tw.ts b/src/runtime/locale/zh_tw.ts index 6f9868c6b5..13fbb7b128 100644 --- a/src/runtime/locale/zh_tw.ts +++ b/src/runtime/locale/zh_tw.ts @@ -20,6 +20,12 @@ export default defineLocale({ increment: '增加', decrement: '減少' }, + fileUpload: { + empty: '瀏覽或拖拽檔案到此處', + removeAll: '移除全部', + addFiles: '新增檔案', + actions: '檔案' + }, commandPalette: { placeholder: '輸入命令或搜尋...', noMatch: '沒有相符的資料', diff --git a/src/runtime/types/index.ts b/src/runtime/types/index.ts index f2a9d6841e..541e960fd3 100644 --- a/src/runtime/types/index.ts +++ b/src/runtime/types/index.ts @@ -49,6 +49,7 @@ export * from '../components/Textarea.vue' export * from '../components/Timeline.vue' export * from '../components/Toast.vue' export * from '../components/Toaster.vue' +export * from '../components/FileUpload.vue' export * from '../components/Tooltip.vue' export * from '../components/Tree.vue' export * from './form' diff --git a/src/runtime/types/locale.ts b/src/runtime/types/locale.ts index 44d63534bb..375a3ad26d 100644 --- a/src/runtime/types/locale.ts +++ b/src/runtime/types/locale.ts @@ -14,6 +14,12 @@ export type Messages = { increment: string decrement: string } + fileUpload: { + empty: string + removeAll: string + addFiles: string + actions: string + } commandPalette: { placeholder: string noMatch: string diff --git a/src/theme/file-upload.ts b/src/theme/file-upload.ts new file mode 100644 index 0000000000..e71ae06f03 --- /dev/null +++ b/src/theme/file-upload.ts @@ -0,0 +1,156 @@ +export default { + slots: { + root: 'group/item relative flex flex-col gap-2 items-center', + base: 'w-full relative flex flex-col items-center overflow-hidden p-4 transition-colors bg-default shadow-sm rounded-md divide-y divide-default overflow-y-auto border border-dashed border-accented', + dragging: 'bg-accented/20', + empty: 'flex flex-col items-center justify-center gap-2', + label: 'font-semibold text-highlighted text-center px-2 line-clamp-1', + uploadIcon: 'pointer-events-none', + files: 'space-y-2 w-full', + filesActions: 'flex items-center justify-between w-full px-1 py-2', + file: 'text-default rounded-md relative', + fileContent: 'flex items-center gap-3', + fileLeadingAvatar: 'shrink-0', + fileLeadingAvatarSize: '', + fileDetails: 'flex-1', + fileLabel: 'text-default font-semibold line-clamp-1', + fileSize: 'text-muted', + fileImage: 'rounded-[inherit]', + fileTrailing: 'flex items-start', + fileRemoveButton: '' + }, + variants: { + size: { + xs: { + empty: 'min-h-16', + label: 'text-xs', + uploadIcon: 'size-4', + files: 'w-full', + file: 'p-1 text-xs gap-1', + fileLeadingAvatarSize: 'xs' + }, + sm: { + empty: 'h-20', + label: 'text-xs', + uploadIcon: 'size-4', + files: 'w-full', + file: 'p-1.5 text-xs gap-1.5', + fileLeadingAvatarSize: 'sm' + }, + md: { + empty: 'h-24', + label: 'text-sm', + uploadIcon: 'size-5', + files: 'w-full', + file: 'p-1.5 text-sm gap-1.5', + fileLeadingAvatarSize: 'md' + }, + lg: { + empty: 'h-26', + label: 'text-sm', + uploadIcon: 'size-5', + files: 'w-full', + file: 'p-2 text-sm gap-2', + fileLeadingAvatarSize: 'lg' + }, + xl: { + empty: 'h-32', + label: 'text-base', + uploadIcon: 'size-6', + files: 'w-full', + file: 'p-2 text-base gap-2', + fileLeadingAvatarSize: 'xl' + } + }, + multiple: { + true: '', + false: '' + }, + layout: { + list: { + files: 'space-y-2', + file: 'flex justify-between items-center gap-2 p-2 border border-accented pe-3', + fileContent: 'flex items-center gap-3', + fileTrailing: 'flex items-start' + }, + grid: '' + }, + disabled: { + true: { + root: 'cursor-not-allowed' + }, + false: '' + }, + dragging: { + true: { + base: 'bg-accented/20' + }, + false: '' + }, + isEmpty: { + true: '', + false: '' + }, + hasDefaultSlot: { + true: '', + false: '' + }, + previewPlacement: { + inside: '', + outside: '' + } + }, + compoundVariants: [ + { + dragging: true, + disabled: true, + class: { + base: 'cursor-not-allowed bg-accented/5' + } + }, + { + multiple: true, + layout: 'grid', + class: { + files: 'grid grid-cols-3 gap-3', + file: 'text-default relative', + fileContent: 'relative rounded-md aspect-square flex items-center justify-center bg-elevated', + fileTrailing: 'absolute -top-1 -right-1', + fileImage: 'size-full object-cover' + } + }, + { + multiple: false, + layout: 'grid', + class: { + files: 'grid grid-cols-1', + file: 'text-default rounded-md min-h-26 relative bg-elevated', + fileContent: 'absolute inset-0 flex items-center justify-center', + fileTrailing: 'absolute -top-2 -right-2', + fileImage: 'mx-auto max-h-full object-contain' + } + }, + { + layout: 'grid', + class: { + fileRemoveButton: 'rounded-full' + } + }, + { + disabled: false, + isEmpty: true, + hasDefaultSlot: false, + class: { + base: 'hover:bg-accented/20' + } + } + ], + defaultVariants: { + size: 'md', + layout: 'list', + disabled: false, + dragging: false, + isEmpty: true, + previewPlacement: 'inside' + } +} diff --git a/src/theme/icons.ts b/src/theme/icons.ts index b0d6357b65..fe6bdeefa6 100644 --- a/src/theme/icons.ts +++ b/src/theme/icons.ts @@ -13,6 +13,9 @@ export default { external: 'i-lucide-arrow-up-right', folder: 'i-lucide-folder', folderOpen: 'i-lucide-folder-open', + upload: 'i-lucide-upload', + trash: 'i-lucide-trash', + file: 'i-lucide-file', loading: 'i-lucide-loader-circle', minus: 'i-lucide-minus', plus: 'i-lucide-plus', diff --git a/src/theme/index.ts b/src/theme/index.ts index c52531309d..06f6d6287f 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -24,6 +24,7 @@ export { default as formField } from './form-field' export { default as input } from './input' export { default as inputMenu } from './input-menu' export { default as inputNumber } from './input-number' +export { default as fileUpload } from './file-upload' export { default as kbd } from './kbd' export { default as link } from './link' export { default as modal } from './modal' diff --git a/test/components/FileUpload.spec.ts b/test/components/FileUpload.spec.ts new file mode 100644 index 0000000000..8a88462ef2 --- /dev/null +++ b/test/components/FileUpload.spec.ts @@ -0,0 +1,271 @@ +import { describe, it, expect, test } from 'vitest' +import { mount } from '@vue/test-utils' +import FileUpload, { type FileUploadProps, type FileUploadSlots, type FileUploadItem } from '../../src/runtime/components/FileUpload.vue' +import theme from '#build/ui/file-upload' + +import { renderForm } from '../utils/form' +import type { FormInputEvents } from '~/src/module' + +async function setFilesOnInput(input: any, files: File[]) { + // Create a DataTransfer and add files + const data = new DataTransfer() + files.forEach(file => data.items.add(file)) + // Set files property via Object.defineProperty + Object.defineProperty(input.element, 'files', { + value: data.files, + writable: false, + configurable: true + }) + // Trigger change event + await input.trigger('change') +} + +describe('FileUpload', () => { + const sizes = Object.keys(theme.variants.size) as any + + it.each([ + // Props + ['with id', { props: { id: 'id' } }], + ['with name', { props: { name: 'name' } }], + ['with multiple', { props: { multiple: true } }], + ['with accept', { props: { accept: 'png,jpg' } }], + ['with disabled', { props: { disabled: true } }], + ['with required', { props: { required: true } }], + ['with label', { props: { label: 'Label' } }], + ['with placeholder', { props: { placeholder: 'Placeholder' } }], + ...sizes.map((size: string) => [`with size ${size}`, { props: { size } }]) + ])('renders %s correctly', async (nameOrHtml: string, options: { props?: FileUploadProps, slots?: Partial }) => { + const wrapper = mount(FileUpload, { + ...options + }) + expect(wrapper.html()).toMatchSnapshot() + }) + + describe('emits', () => { + test('update:modelValue event', async () => { + const wrapper = mount(FileUpload, { + props: { + modelValue: [] + } + }) + const input = wrapper.find('input[type="file"]') + const file1 = new File(['foo'], 'file1.txt', { type: 'text/plain' }) + const file2 = new File(['bar'], 'file2.txt', { type: 'text/plain' }) + await setFilesOnInput(input, [file1, file2]) + expect(wrapper.emitted('update:modelValue')).toBeTruthy() + }) + test('change event', async () => { + const wrapper = mount(FileUpload, { + props: { + modelValue: [] + } + }) + const input = wrapper.find('input[type="file"]') + const file1 = new File(['foo'], 'file1.txt', { type: 'text/plain' }) + const file2 = new File(['bar'], 'file2.txt', { type: 'text/plain' }) + await setFilesOnInput(input, [file1, file2]) + expect(wrapper.emitted('change')).toBeTruthy() + }) + test('dragover event', async () => { + const wrapper = mount(FileUpload, { + props: { + modelValue: [] + } + }) + const input = wrapper.find('input[type="file"]') + await input.trigger('dragover') + expect(wrapper.emitted('dragover')).toBeTruthy() + } + ) + test('dragleave event', async () => { + const wrapper = mount(FileUpload, { + props: { + modelValue: [] + } + }) + const input = wrapper.find('input[type="file"]') + await input.trigger('dragleave') + expect(wrapper.emitted('dragleave')).toBeTruthy() + } + ) + test('drop event', async () => { + const wrapper = mount(FileUpload, { + props: { + modelValue: [] + } + }) + const input = wrapper.find('input[type="file"]') + await input.trigger('drop') + expect(wrapper.emitted('drop')).toBeTruthy() + } + ) + }) + + describe('form integration', async () => { + async function createForm(validateOn?: FormInputEvents[], eagerValidation?: boolean) { + const wrapper = await renderForm({ + props: { + validateOn, + validateOnInputDelay: 0, + async validate(state: any) { + // state.value is expected to be an array of FileUploadItem(s) + const files: FileUploadItem[] = Array.isArray(state.value) ? state.value : [] + if (!files.length || files.some(f => f.file.name !== 'valid')) { + return [{ name: 'value', message: 'Error message' }] + } + return [] + } + }, + slotTemplate: ` + + + + `, + slotVars: { + eagerValidation + } + }) + const input = wrapper.find('input[type="file"]') + return { + wrapper, + input + } + } + + test('validate on blur works', async () => { + const { input, wrapper } = await createForm(['blur']) + await setFilesOnInput(input, [new File(['foo'], 'invalid.txt', { type: 'text/plain' })]) + await input.trigger('blur') + expect(wrapper.text()).toContain('Error message') + + await setFilesOnInput(input, [new File(['foo'], 'valid', { type: 'text/plain' })]) + await input.trigger('blur') + expect(wrapper.text()).not.toContain('Error message') + }) + + test('validate on change works', async () => { + const { input, wrapper } = await createForm(['change']) + await setFilesOnInput(input, [new File(['foo'], 'invalid.txt', { type: 'text/plain' })]) + await input.trigger('change') + expect(wrapper.text()).toContain('Error message') + + await setFilesOnInput(input, [new File(['foo'], 'valid', { type: 'text/plain' })]) + await input.trigger('change') + expect(wrapper.text()).not.toContain('Error message') + }) + + test('validate on input works', async () => { + const { input, wrapper } = await createForm(['input'], true) + await setFilesOnInput(input, [new File(['foo'], 'invalid.txt', { type: 'text/plain' })]) + expect(wrapper.text()).toContain('Error message') + + await setFilesOnInput(input, [new File(['foo'], 'valid', { type: 'text/plain' })]) + expect(wrapper.text()).not.toContain('Error message') + }) + + test('validate on input without eager validation works', async () => { + const { input, wrapper } = await createForm(['input']) + + await setFilesOnInput(input, [new File(['foo'], 'invalid.txt', { type: 'text/plain' })]) + expect(wrapper.text()).not.toContain('Error message') + + await input.trigger('blur') + + await setFilesOnInput(input, [new File(['foo'], 'invalid.txt', { type: 'text/plain' })]) + expect(wrapper.text()).toContain('Error message') + + await setFilesOnInput(input, [new File(['foo'], 'valid', { type: 'text/plain' })]) + expect(wrapper.text()).not.toContain('Error message') + }) + }) + + describe('FileUpload advanced behaviors', () => { + test('shows image preview and removes it when file is removed', async () => { + const file = new File(['dummy'], 'test.png', { type: 'image/png', lastModified: 1 }) + const wrapper = mount(FileUpload, { + props: { modelValue: [{ file }] } + }) + await wrapper.vm.$nextTick() + + expect(wrapper.html()).toContain('test.png') + const removeIcon = wrapper.find('#remove-file') + expect(removeIcon).toBeDefined() + await removeIcon!.trigger('click') + + // Check that update:modelValue was emitted with an empty array + const emits = wrapper.emitted('update:modelValue') + expect(emits).toBeTruthy() + expect(emits![emits!.length - 1][0]).toEqual([]) + }) + + test('does not allow interaction when disabled', async () => { + const wrapper = mount(FileUpload, { + props: { disabled: true } + }) + const input = wrapper.find('input[type="file"]') + expect(input.attributes('disabled')).toBeDefined() + await wrapper.find('div[role="presentation"],div').trigger('click') + expect(wrapper.emitted('change')).toBeFalsy() + }) + + test('handles multiple file uploads', async () => { + const file1 = new File(['foo'], 'foo.png', { type: 'image/png', lastModified: 1 }) + const file2 = new File(['bar'], 'bar.jpg', { type: 'image/jpeg', lastModified: 2 }) + const wrapper = mount(FileUpload, { + props: { multiple: true, modelValue: [] } + }) + const input = wrapper.find('input[type="file"]') + await setFilesOnInput(input, [file1, file2]) + expect(wrapper.emitted('update:modelValue')).toBeTruthy() + const lastEmitted = wrapper.emitted('update:modelValue')?.pop()?.[0] + expect(lastEmitted).toHaveLength(2) + }) + + test('accept prop restricts file types', async () => { + const file = new File(['foo'], 'foo.txt', { type: 'text/plain' }) + const wrapper = mount(FileUpload, { + props: { accept: 'image/*', modelValue: [] } + }) + const input = wrapper.find('input[type="file"]') + await setFilesOnInput(input, [file]) + expect(input.attributes('accept')).toBe('image/*') + }) + + test('renders custom empty slot', () => { + const wrapper = mount(FileUpload, { + slots: { + empty: 'Custom Empty' + } + }) + expect(wrapper.html()).toContain('Custom Empty') + }) + + test('renders custom item slot', async () => { + const file = new File(['foo'], 'foo.png', { type: 'image/png', lastModified: 1 }) + const wrapper = mount(FileUpload, { + props: { modelValue: [{ file }] }, + slots: { + item: '{{item.file.name}}' + } + }) + expect(wrapper.html()).toContain('custom-item') + expect(wrapper.html()).toContain('foo.png') + }) + + test('renders custom item slots with correct type', async () => { + const file = new File(['foo'], 'foo.png', { type: 'image/png', lastModified: 1 }) + type UploadWithStatus = FileUploadItem<{ status: 'pending' | 'uploading' | 'done', progress?: number }> + const wrapper = mount(FileUpload, { + props: { + modelValue: [{ file, status: 'pending' }] + }, + slots: { + item: '{{item.file.name}} - {{item.status}}' + } + + }) + expect(wrapper.html()).toContain('custom-item') + expect(wrapper.html()).toContain('foo.png - pending') + }) + }) +}) diff --git a/test/components/__snapshots__/FileUpload-vue.spec.ts.snap b/test/components/__snapshots__/FileUpload-vue.spec.ts.snap new file mode 100644 index 0000000000..a0b9bbde05 --- /dev/null +++ b/test/components/__snapshots__/FileUpload-vue.spec.ts.snap @@ -0,0 +1,144 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`FileUpload > renders with accept correctly 1`] = ` +" + + + Browse or drop files here + + + +" +`; + +exports[`FileUpload > renders with disabled correctly 1`] = ` +" + + + Browse or drop files here + + + +" +`; + +exports[`FileUpload > renders with id correctly 1`] = ` +" + + + Browse or drop files here + + + +" +`; + +exports[`FileUpload > renders with label correctly 1`] = ` +" + + + Label + + + +" +`; + +exports[`FileUpload > renders with multiple correctly 1`] = ` +" + + + Browse or drop files here + + + +" +`; + +exports[`FileUpload > renders with name correctly 1`] = ` +" + + + Browse or drop files here + + + +" +`; + +exports[`FileUpload > renders with placeholder correctly 1`] = ` +" + + + Browse or drop files here + + + +" +`; + +exports[`FileUpload > renders with required correctly 1`] = ` +" + + + Browse or drop files here + + + +" +`; + +exports[`FileUpload > renders with size lg correctly 1`] = ` +" + + + Browse or drop files here + + + +" +`; + +exports[`FileUpload > renders with size md correctly 1`] = ` +" + + + Browse or drop files here + + + +" +`; + +exports[`FileUpload > renders with size sm correctly 1`] = ` +" + + + Browse or drop files here + + + +" +`; + +exports[`FileUpload > renders with size xl correctly 1`] = ` +" + + + Browse or drop files here + + + +" +`; + +exports[`FileUpload > renders with size xs correctly 1`] = ` +" + + + Browse or drop files here + + + +" +`; diff --git a/test/components/__snapshots__/FileUpload.spec.ts.snap b/test/components/__snapshots__/FileUpload.spec.ts.snap new file mode 100644 index 0000000000..d731da579a --- /dev/null +++ b/test/components/__snapshots__/FileUpload.spec.ts.snap @@ -0,0 +1,144 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`FileUpload > renders with accept correctly 1`] = ` +" + + + Browse or drop files here + + + +" +`; + +exports[`FileUpload > renders with disabled correctly 1`] = ` +" + + + Browse or drop files here + + + +" +`; + +exports[`FileUpload > renders with id correctly 1`] = ` +" + + + Browse or drop files here + + + +" +`; + +exports[`FileUpload > renders with label correctly 1`] = ` +" + + + Label + + + +" +`; + +exports[`FileUpload > renders with multiple correctly 1`] = ` +" + + + Browse or drop files here + + + +" +`; + +exports[`FileUpload > renders with name correctly 1`] = ` +" + + + Browse or drop files here + + + +" +`; + +exports[`FileUpload > renders with placeholder correctly 1`] = ` +" + + + Browse or drop files here + + + +" +`; + +exports[`FileUpload > renders with required correctly 1`] = ` +" + + + Browse or drop files here + + + +" +`; + +exports[`FileUpload > renders with size lg correctly 1`] = ` +" + + + Browse or drop files here + + + +" +`; + +exports[`FileUpload > renders with size md correctly 1`] = ` +" + + + Browse or drop files here + + + +" +`; + +exports[`FileUpload > renders with size sm correctly 1`] = ` +" + + + Browse or drop files here + + + +" +`; + +exports[`FileUpload > renders with size xl correctly 1`] = ` +" + + + Browse or drop files here + + + +" +`; + +exports[`FileUpload > renders with size xs correctly 1`] = ` +" + + + Browse or drop files here + + + +" +`; diff --git a/test/utils/form.ts b/test/utils/form.ts index 1a8f3b6f3b..5f8d009510 100644 --- a/test/utils/form.ts +++ b/test/utils/form.ts @@ -13,6 +13,7 @@ import { USelectMenu, UInputMenu, UInputNumber, + UFileUpload, USwitch, USlider, UPinInput, @@ -44,6 +45,7 @@ export async function renderForm(options: { UForm, UInput, URadioGroup, + UFileUpload, UTextarea, UCheckbox, USelect,
+ {{ text }}. Must satisfy: +
+ {{ item.file.name }} +
+ + {{ (item.file.size / 1024 / 1024).toFixed(2) }} MB +