feat(upload): upload component (#168)

* feat(upload): upload component

* bump version

* fix(upload): fix typo & doc

\

Co-authored-by: Herrington Darkholme <2883231+HerringtonDarkholme@users.noreply.github.com>
This commit is contained in:
jeremywu 2020-09-27 12:10:36 +08:00 committed by GitHub
parent 12814a868a
commit cce1b5a252
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1665 additions and 81 deletions

View File

@ -37,6 +37,7 @@ import ElDialog from '@element-plus/dialog'
import ElCalendar from '@element-plus/calendar'
import ElInfiniteScroll from '@element-plus/infinite-scroll'
import ElDrawer from '@element-plus/drawer'
import ElUpload from '@element-plus/upload'
import ElTree from '@element-plus/tree'
export {
@ -77,6 +78,7 @@ export {
ElCalendar,
ElInfiniteScroll,
ElDrawer,
ElUpload,
ElTree,
}
@ -119,6 +121,7 @@ const install = (app: App): void => {
ElCalendar(app)
ElInfiniteScroll(app)
ElDrawer(app)
ElUpload(app)
ElTree(app)
}

View File

@ -3,3 +3,5 @@ import Progress from './src/index.vue'
export default (app: App): void => {
app.component(Progress.name, Progress)
}
export const ElProgress = Progress

View File

@ -0,0 +1,8 @@
import { mount } from '@vue/test-utils'
import { merge } from 'lodash'
const makeMount = <C,O, E>(element: C, defaultOptions: O) => {
return (props: (E | O) | (E & O)= {} as E) => mount(element, merge({}, defaultOptions, props))
}
export default makeMount

View File

@ -0,0 +1,68 @@
import { provide, h, defineComponent } from 'vue'
import makeMount from '../../test-utils/make-mount'
import UploadDragger from '../src/upload-dragger.vue'
const AXIOM = 'Rem is the best girl'
const Wrapper = defineComponent({
props: {
onDrop: Function,
},
setup(props, { slots }) {
provide('uploader', { accept: 'video/*' })
return () => h(UploadDragger, props, slots)
},
})
const mount = makeMount(Wrapper, {
slots: {
default: () => AXIOM,
},
})
describe('<upload-dragger />', () => {
describe('render test', () => {
test('should render correct', () => {
const wrapper = mount()
expect(wrapper.text()).toBe(AXIOM)
})
})
describe('functionality', () => {
test('onDrag works', async () => {
const wrapper = mount()
await wrapper.find('.el-upload-dragger').trigger('dragover')
expect(wrapper.classes('is-dragover')).toBe(true)
})
test('ondrop works for any given video type', async () => {
const onDrop = jest.fn()
const wrapper = mount({
props: {
onDrop,
},
})
const dragger = wrapper.findComponent(UploadDragger)
await dragger.trigger('drop', {
dataTransfer: {
files: [{
type: 'video/mp4',
name: 'test.mp4',
}],
},
})
expect(onDrop).toHaveBeenCalledTimes(1)
expect(dragger.emitted('file')).toHaveLength(1)
await dragger.trigger('drop', {
dataTransfer: {
files: [{
type: 'video/mov',
name: 'test.mov',
}],
},
})
expect(dragger.emitted('file')).toHaveLength(2)
})
})
})

View File

@ -0,0 +1,80 @@
import { h } from 'vue'
import { EVENT_CODE } from '@element-plus/utils/aria'
import makeMount from '../../test-utils/make-mount'
import UploadList from '../src/upload-list.vue'
const testName = 'test name'
const mount = makeMount(UploadList, {
props: {
files: [new File([], testName)],
},
})
describe('<upload-list />', () => {
describe('render test', () => {
test('should render correct', () => {
const wrapper = mount({
slots: {
default: ({ file }: { file: File; } ) => h('div', null, file.name),
},
})
expect(wrapper.text()).toBe(testName)
})
})
describe('functionalities', () => {
test('handle preview works', async () => {
const preview = jest.fn()
const wrapper = mount({
props: {
handlePreview: preview,
},
})
await wrapper.find('.el-upload-list__item-name').trigger('click')
expect(preview).toHaveBeenCalled()
await wrapper.setProps({
listType: 'picture-card',
})
await wrapper.find('.el-upload-list__item-preview').trigger('click')
expect(preview).toHaveBeenCalledTimes(2)
})
test('handle delete works', async () => {
const remove = jest.fn()
const wrapper = mount({
props: {
onRemove: remove,
},
})
await wrapper.find('.el-icon-close').trigger('click')
expect(remove).toHaveBeenCalled()
await wrapper.find('.el-upload-list__item').trigger('keydown', {
key: EVENT_CODE.delete,
})
expect(remove).toHaveBeenCalledTimes(2)
await wrapper.setProps({
listType: 'picture-card',
})
await wrapper.find('.el-upload-list__item-delete').trigger('click')
expect(remove).toHaveBeenCalledTimes(3)
})
})
})

View File

@ -0,0 +1,171 @@
import { nextTick } from 'vue'
import { on } from '@element-plus/utils/dom'
import { EVENT_CODE } from '../../utils/aria'
import makeMount from '../../test-utils/make-mount'
import Upload from '../src/upload.vue'
const AXIOM = 'Rem is the best girl'
const action = 'test-action'
interface MockFile {
name: string
body: string
mimeType: string
}
const mockGetFile = (element: HTMLInputElement, files: File[]) => {
Object.defineProperty(element, 'files', {
get() {
return files
},
})
}
const mount = makeMount(Upload, {
props: {
action,
},
slots: {
default: () => AXIOM,
},
})
describe('<upload />', () => {
describe('render test', () => {
test('basic rendering', async () => {
const wrapper = mount()
expect(wrapper.text()).toEqual(AXIOM)
await wrapper.setProps({
drag: true,
})
expect(wrapper.find('.el-upload-dragger').exists()).toBe(true)
})
})
describe('functionality', () => {
test('works with keydown & click', async () => {
const wrapper = mount()
const click = jest.fn()
on(wrapper.find('input').element, 'click', click)
await wrapper.trigger('click')
expect(click).toHaveBeenCalled()
await wrapper.trigger('keydown', {
key: EVENT_CODE.enter,
})
expect(click).toHaveBeenCalledTimes(2)
await wrapper.trigger('keydown', {
key: EVENT_CODE.space,
})
expect(click).toHaveBeenCalledTimes(3)
})
test('works when upload file exceeds the limit', async () => {
const onExceed = jest.fn()
const wrapper = mount({
props: {
onExceed,
limit: 1,
},
})
const fileList = [
new File(['content'], 'test-file.txt'),
new File(['content'], 'test-file.txt'),
]
mockGetFile(wrapper.find('input').element, fileList)
await wrapper.find('input').trigger('change')
expect(onExceed).toHaveBeenCalled()
})
test('onStart works', async () => {
const onStart = jest.fn()
const wrapper = mount({
props: {
onStart,
autoUpload: false, // prevent auto upload
},
})
const fileList = [new File(['content'], 'test-file.txt')]
mockGetFile(wrapper.find('input').element, fileList)
await wrapper.find('input').trigger('change')
expect(onStart).toHaveBeenCalled()
})
test('beforeUpload works for rejecting upload', async () => {
const beforeUpload = jest.fn(() => Promise.reject())
const onRemove = jest.fn()
const wrapper = mount({
props: {
beforeUpload,
onRemove,
},
})
const fileList = [new File(['content'], 'test-file.txt')]
mockGetFile(wrapper.find('input').element, fileList)
await wrapper.find('input').trigger('change')
expect(beforeUpload).toHaveBeenCalled()
await nextTick()
expect(onRemove).toHaveBeenCalled()
})
test('beforeUpload works for resolving upload', async () => {
const beforeUpload = jest.fn(() => Promise.resolve())
const httpRequest = jest.fn(() => Promise.resolve())
const onSuccess = jest.fn()
const wrapper = mount({
props: {
beforeUpload,
httpRequest,
onSuccess,
},
})
const fileList = [new File(['content'], 'test-file.txt')]
mockGetFile(wrapper.find('input').element, fileList)
await wrapper.find('input').trigger('change')
expect(beforeUpload).toHaveBeenCalled()
await nextTick()
// await nextTick()
expect(onSuccess).toHaveBeenCalled()
const onError = jest.fn()
await wrapper.setProps({
httpRequest: jest.fn(() => Promise.reject()),
onError,
})
await wrapper.find('input').trigger('change')
await nextTick()
expect(onError).toHaveBeenCalled()
})
test('onProgress should work', async () => {
const onProgress = jest.fn()
const httpRequest = jest.fn(({ onProgress }) => {
onProgress()
return Promise.resolve()
})
const wrapper = mount({
props: {
httpRequest,
onProgress,
},
})
const fileList = [new File(['content'], 'test-file.txt')]
mockGetFile(wrapper.find('input').element, fileList)
await wrapper.find('input').trigger('change')
await nextTick()
expect(onProgress).toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,42 @@
<template>
<el-upload
class="upload-demo"
action="https://jsonplaceholder.typicode.com/posts/"
:on-preview="handlePreview"
:on-remove="handleRemove"
:before-remove="beforeRemove"
:multiple="multiple"
:limit="3"
:on-exceed="handleExceed"
:file-list="fileList"
>
<el-button size="small" type="primary">Click to upload</el-button>
<template #tip>
<div class="el-upload__tip">jpg/png files with a size less than 500kb</div>
</template>
</el-upload>
</template>
<script lang="ts">
export default {
data() {
return {
fileList: [{ name: 'food.jpeg', url: 'https://fuss10.elemecdn.com/3/63/4e7f3a15429bfda99bce42a18cdd1jpeg.jpeg?imageMogr2/thumbnail/360x360/format/webp/quality/100' }, { name: 'food2.jpeg', url: 'https://fuss10.elemecdn.com/3/63/4e7f3a15429bfda99bce42a18cdd1jpeg.jpeg?imageMogr2/thumbnail/360x360/format/webp/quality/100' }],
}
},
methods: {
handleRemove(file, fileList) {
console.log(file, fileList)
},
handlePreview(file) {
console.log(file)
},
handleExceed(files, fileList) {
this.$message.warning(`The limit is 3, you selected ${files.length} files this time, add up to ${files.length + fileList.length} totally`)
},
beforeRemove(file, fileList) {
console.log(`Cancel the transfert of ${ file.name } ?`)
console.log(fileList)
},
},
}
</script>

View File

@ -0,0 +1,17 @@
<template>
<el-upload
class="upload-demo"
drag
action="https://jsonplaceholder.typicode.com/posts/"
:on-preview="handlePreview"
:on-remove="handleRemove"
:file-list="fileList"
multiple
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">Drop file here or <em>click to upload</em></div>
<template #tip>
<div class="el-upload__tip">jpg/png files with a size less than 500kb</div>
</template>
</el-upload>
</template>

View File

@ -0,0 +1,7 @@
export default {
title: 'Upload',
}
export { default as BasicUsage } from './basic.vue'
export { default as ManualUpload } from './manual.vue'
export { default as DropToUpload } from './drop-to-upload.vue'

View File

@ -0,0 +1,32 @@
<template>
<el-upload
ref="upload"
class="upload-demo"
action="https://jsonplaceholder.typicode.com/posts/"
:auto-upload="false"
>
<template #trigger>
<el-button size="small" type="primary">select file</el-button>
</template>
<el-button
style="margin-left: 10px;"
size="small"
type="success"
@click="submitUpload"
>
upload to server
</el-button>
<template #tip>
<div class="el-upload__tip">jpg/png files with a size less than 500kb</div>
</template>
</el-upload>
</template>
<script>
export default {
methods: {
submitUpload() {
this.$refs.upload.submit()
},
},
}
</script>

5
packages/upload/index.ts Normal file
View File

@ -0,0 +1,5 @@
import { App } from 'vue'
import Upload from './src/index.vue'
export default (app: App): void => {
app.component(Upload.name, Upload)
}

View File

@ -0,0 +1,12 @@
{
"name": "@element-plus/upload",
"version": "0.0.0",
"main": "dist/index.js",
"license": "MIT",
"peerDependencies": {
"vue": "^3.0.0"
},
"devDependencies": {
"@vue/test-utils": "^2.0.0-beta.3"
}
}

View File

@ -0,0 +1,91 @@
import type {
ElUploadProgressEvent,
ElUploadRequestOptions,
ElUploadAjaxError,
} from './upload'
function getError(action: string, option: ElUploadRequestOptions, xhr: XMLHttpRequest) {
let msg: string
if (xhr.response) {
msg = `${xhr.response.error || xhr.response}`
} else if (xhr.responseText) {
msg = `${xhr.responseText}`
} else {
msg = `fail to post ${action} ${xhr.status}`
}
const err = new Error(msg) as ElUploadAjaxError
err.status = xhr.status
err.method = 'post'
err.url = action
return err
}
function getBody(xhr: XMLHttpRequest): XMLHttpRequestResponseType {
const text = xhr.responseText || xhr.response
if (!text) {
return text
}
try {
return JSON.parse(text)
} catch (e) {
return text
}
}
export default function upload(option: ElUploadRequestOptions) {
if (typeof XMLHttpRequest === 'undefined') {
return
}
const xhr = new XMLHttpRequest()
const action = option.action
if (xhr.upload) {
xhr.upload.onprogress = function progress(e) {
if (e.total > 0) {
(e as ElUploadProgressEvent).percent = e.loaded / e.total * 100
}
option.onProgress(e)
}
}
const formData = new FormData()
if (option.data) {
Object.keys(option.data).forEach(key => {
formData.append(key, option.data[key])
})
}
formData.append(option.filename, option.file, option.file.name)
xhr.onerror = function error() {
option.onError(getError(action, option, xhr))
}
xhr.onload = function onload() {
if (xhr.status < 200 || xhr.status >= 300) {
return option.onError(getError(action, option, xhr))
}
option.onSuccess(getBody(xhr))
}
xhr.open('post', action, true)
if (option.withCredentials && 'withCredentials' in xhr) {
xhr.withCredentials = true
}
const headers = option.headers || {}
for (const item in headers) {
if (headers.hasOwnProperty(item) && headers[item] !== null) {
xhr.setRequestHeader(item, headers[item])
}
}
xhr.send(formData)
return xhr
}

View File

@ -0,0 +1,247 @@
<script lang='ts'>
import {
computed,
defineComponent,
h,
getCurrentInstance,
inject,
ref,
provide,
onBeforeUnmount,
} from 'vue'
import { NOOP } from '@vue/shared'
import ajax from './ajax'
import UploadList from './upload-list.vue'
import Upload from './upload.vue'
import useHandlers from './useHandlers'
import type { PropType } from 'vue'
import type {
ListType,
UploadFile,
FileHandler,
FileResultHandler,
} from './upload'
type PFileHandler<T> = PropType<FileHandler<T>>
type PFileResultHandler<T = any> = PropType<FileResultHandler<T>>
export default defineComponent({
name: 'ElUpload',
components: {
Upload,
UploadList,
},
props: {
action: {
type: String,
required: true,
},
headers: {
type: Object as PropType<Headers>,
default: () => ({}),
},
data: {
type: Object,
default: () => ({}),
},
multiple: {
type: Boolean,
default: false,
},
name: {
type: String,
default: 'file',
},
drag: {
type: Boolean,
default: false,
},
withCredentials: Boolean,
showFileList: {
type: Boolean,
default: true,
},
accept: {
type: String,
default: '',
},
type: {
type: String,
default: 'select',
},
beforeUpload: {
type: Function as PFileHandler<void>,
default: NOOP,
},
beforeRemove: {
type: Function as PFileHandler<boolean>,
default: NOOP,
},
onRemove: {
type: Function as PFileHandler<void>,
default: NOOP,
},
onChange: {
type: Function as PFileHandler<void>,
default: NOOP,
},
onPreview: {
type: Function as PropType<() => void>,
default: NOOP,
},
onSuccess: {
type: Function as PFileResultHandler,
default: NOOP,
},
onProgress: {
type: Function as PFileResultHandler<ProgressEvent>,
default: NOOP,
},
onError: {
type: Function as PFileResultHandler<Error>,
default: NOOP,
},
fileList: {
type: Array as PropType<UploadFile[]>,
default: () => {
return [] as UploadFile[]
},
},
autoUpload: {
type: Boolean,
default: true,
},
listType: {
type: String as PropType<ListType>,
default: 'text' as ListType, // text,picture,picture-card
},
httpRequest: {
type: Function,
default: ajax,
},
disabled: Boolean,
limit: {
type: Number as PropType<Nullable<number>>,
default: null,
},
onExceed: {
type: Function,
default: () => NOOP,
},
},
setup(props) {
// init here
const elForm = inject('elForm', {} as { disabled: boolean; })
const uploadDisabled = computed(() => {
return props.disabled || elForm.disabled
})
const {
clearFiles,
handleError,
handleProgress,
handleStart,
handleSuccess,
handleRemove,
submit,
uploadRef,
uploadFiles,
} = useHandlers(props)
provide('uploader', getCurrentInstance())
onBeforeUnmount(() => {
uploadFiles.value.forEach(file => {
if (file.url && file.url.indexOf('blob:') === 0) {
URL.revokeObjectURL(file.url)
}
})
})
return {
dragOver: ref(false),
draging: ref(false),
handleError,
handleProgress,
handleRemove,
handleStart,
handleSuccess,
uploadDisabled,
uploadFiles,
uploadRef,
submit,
clearFiles,
}
},
render() {
let uploadList
if (this.showFileList) {
uploadList = h(
UploadList,
{
disabled: this.uploadDisabled,
listType: this.listType,
files: this.uploadFiles,
onRemove: this.handleRemove,
handlePreview: this.onPreview,
},
{
file: (props: { file: UploadFile; }) => {
if (this.$slots.file) {
return this.$slots.file({
file: props.file,
})
}
return null
},
},
)
} else {
uploadList = null
}
const uploadData = {
type: this.type,
drag: this.drag,
action: this.action,
multiple: this.multiple,
'before-upload': this.beforeUpload,
'with-credentials': this.withCredentials,
headers: this.headers,
name: this.name,
data: this.data,
accept: this.accept,
fileList: this.uploadFiles,
autoUpload: this.autoUpload,
listType: this.listType,
disabled: this.uploadDisabled,
limit: this.limit,
'on-exceed': this.onExceed,
'on-start': this.handleStart,
'on-progress': this.handleProgress,
'on-success': this.handleSuccess,
'on-error': this.handleError,
'on-preview': this.onPreview,
'on-remove': this.handleRemove,
'http-request': this.httpRequest,
ref: 'uploadRef',
}
const trigger = this.$slots.trigger || this.$slots.default
const uploadComponent = h(Upload, uploadData, {
default: () => trigger?.(),
})
return h('div', [
this.listType === 'picture-card' ? uploadList : null,
this.$slots.trigger
? [uploadComponent, this.$slots.default()]
: uploadComponent,
this.$slots.tip?.(),
this.listType !== 'picture-card' ? uploadList : null,
])
},
})
</script>

View File

@ -0,0 +1,78 @@
<template>
<div
:class="{
'el-upload-dragger': true,
'is-dragover': dragover
}"
@drop.prevent="onDrop"
@dragover.prevent="onDragover"
@dragleave.prevent="dragover = false"
>
<slot></slot>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, inject } from 'vue'
import type { ElUpload } from './upload'
export default defineComponent({
name: 'ElUploadDrag',
props: {
disabled: {
type: Boolean,
default: false,
},
},
emits: ['file'],
setup(props, { emit }) {
const uploader = inject('uploader', {} as ElUpload)
const dragover = ref(false)
function onDrop(e: DragEvent) {
if (props.disabled || !uploader) return
const accept = uploader.accept
dragover.value = false
if (!accept) {
emit('file', e.dataTransfer.files)
return
}
emit(
'file',
Array.from(e.dataTransfer.files).filter(file => {
const { type, name } = file
const extension =
name.indexOf('.') > -1 ? `.${name.split('.').pop()}` : ''
const baseType = type.replace(/\/.*$/, '')
return accept
.split(',')
.map(type => type.trim())
.filter(type => type)
.some(acceptedType => {
if (acceptedType.startsWith('.')) {
return extension === acceptedType
}
if (/\/\*$/.test(acceptedType)) {
return baseType === acceptedType.replace(/\/\*$/, '')
}
if (/^[^\/]+\/[^\/]+$/.test(acceptedType)) {
return type === acceptedType
}
return false
})
}),
)
}
function onDragover() {
if (!props.disabled) dragover.value = true
}
return {
dragover,
onDrop,
onDragover,
}
},
})
</script>

View File

@ -0,0 +1,127 @@
<template>
<transition-group
tag="ul"
:class="[
'el-upload-list',
'el-upload-list--' + listType,
{ 'is-disabled': disabled }
]"
name="el-list"
>
<li
v-for="(file, idx) in files"
:key="idx"
:class="['el-upload-list__item', 'is-' + file.status, focusing ? 'focusing' : '']"
tabindex="0"
@keydown.delete="!disabled && handleRemove($event, file)"
@focus="focusing = true"
@blur="focusing = false"
@click="onFileClicked"
>
<slot :file="file">
<img
v-if="file.status !== 'uploading' && ['picture-card', 'picture'].includes(listType)"
class="el-upload-list__item-thumbnail"
:src="file.url"
alt=""
>
<a class="el-upload-list__item-name" @click="handleClick(file)">
<i class="el-icon-document"></i>{{ file.name }}
</a>
<label class="el-upload-list__item-status-label">
<i
:class="{
'el-icon-upload-success': true,
'el-icon-circle-check': listType === 'text',
'el-icon-check': ['picture-card', 'picture'].includes(listType)
}"
></i>
</label>
<i v-if="!disabled" class="el-icon-close" @click="handleRemove($event, file)"></i>
<!-- Due to close btn only appears when li gets focused disappears after li gets blurred, thus keyboard navigation can never reach close btn-->
<!-- This is a bug which needs to be fixed -->
<!-- TODO: Fix the incorrect navigation interaction -->
<i v-if="!disabled" class="el-icon-close-tip">{{ t('el.upload.deleteTip') }}</i>
<el-progress
v-if="file.status === 'uploading'"
:type="listType === 'picture-card' ? 'circle' : 'line'"
:stroke-width="listType === 'picture-card' ? 6 : 2"
:percentage="parsePercentage(file.percentage)"
/>
<span v-if="listType === 'picture-card'" class="el-upload-list__item-actions">
<span
class="el-upload-list__item-preview"
@click="handlePreview(file)"
>
<i class="el-icon-zoom-in"></i>
</span>
<span
v-if="!disabled"
class="el-upload-list__item-delete"
@click="handleRemove($event, file)"
>
<i class="el-icon-delete"></i>
</span>
</span>
</slot>
</li>
</transition-group>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue'
import { NOOP } from '@vue/shared'
import { t } from '@element-plus/locale'
import { ElProgress } from '@element-plus/progress'
import type { PropType } from 'vue'
export default defineComponent({
name: 'ElUploadList',
components: { ElProgress },
props: {
files: {
type: Array as PropType<File[]>,
default: () => [] as File[],
},
disabled: {
type: Boolean,
default: false,
},
handlePreview: {
type: Function as PropType<(file: File) => void>,
default: () => NOOP,
},
listType: {
type: String as PropType<'picture' | 'picture-card' | 'text'>,
default: 'text',
},
},
emits: ['remove'],
setup(props, { emit }) {
const parsePercentage = (val: string) => {
return parseInt(val, 10)
}
const handleClick = (file: File) => {
props.handlePreview(file)
}
const onFileClicked = (e: Event) => {
(e.target as HTMLElement).focus()
}
const handleRemove = (e: Event, file: File) => {
emit('remove', file)
}
return {
focusing: ref(false),
parsePercentage,
handleClick,
handleRemove,
onFileClicked,
t,
}
},
})
</script>

80
packages/upload/src/upload.d.ts vendored Normal file
View File

@ -0,0 +1,80 @@
export type ListType = 'text' | 'picture' | 'picture-card'
export type UploadStatus = 'ready' | 'uploading' | 'success' | 'fail'
export type UploadFile = {
name: string
percentage?: number
status: UploadStatus
size: number
response?: unknown
uid: number
url?: string
raw: ElFile
}
export interface ElFile extends File {
uid: number
}
export interface ElUploadProgressEvent extends ProgressEvent {
percent: number
}
export interface ElUploadAjaxError extends Error {
status: number
method: string
url: string
}
export interface ElUploadRequestOptions {
action: string
data: Record<string, string | Blob>
filename: string
file: File
headers: Headers
onError: (e: Error) => void
onProgress: (e: ProgressEvent) => void
onSuccess: (response: XMLHttpRequestResponseType) => unknown
withCredentials: boolean
}
export type FileHandler<T = void> = (file: UploadFile, uploadFiles: UploadFile[]) => T
export type FileResultHandler<T = any> = (param: T, file: UploadFile, uploadFiles: UploadFile[]) => void
export interface IUseHandlersProps {
listType: ListType
fileList: UploadFile[]
beforeUpload?: FileHandler
beforeRemove?: FileHandler<Promise<any> | boolean>
onRemove?: FileHandler
onChange?: FileHandler
onPreview?: () => void
onSuccess?: FileResultHandler
onProgress?: FileResultHandler<ProgressEvent>
onError?: FileResultHandler<Error>
}
export interface ElUpload extends IUseHandlersProps {
accept: string
headers?: Headers
data?: Record<string, unknown>
multiple?: boolean
name?: string
drag?: boolean
withCredentials?: boolean
showFileList?: boolean
type?: string
dragOver: boolean
genUid: () => number
tempIndex: number
handleError: () => void
handleProgress: () => void
handleRemove: () => void
handleStart: () => void
handleSuccess: () => void
uploadDisabled: boolean
uploadFiles: UploadFile[]
submit: () => void
clearFiles: () => void
}

View File

@ -0,0 +1,282 @@
<template>
<div
:class="['el-upload', `el-upload--${listType}`]"
tabindex="0"
@click="handleClick"
@keydown.self.enter.space="handleKeydown"
>
<template v-if="drag">
<upload-dragger :disabled="disabled" @file="uploadFiles">
<slot></slot>
</upload-dragger>
</template>
<template v-else>
<slot></slot>
</template>
<input
ref="inputRef"
class="el-upload__input"
type="file"
:name="name"
:multiple="multiple"
:accept="accept"
@change="handleChange"
>
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue'
import { NOOP } from '@vue/shared'
import ajax from './ajax'
import UploadDragger from './upload-dragger.vue'
import type { PropType } from 'vue'
import type { ListType, UploadFile, ElFile } from './upload'
type IFileHanlder = (
file: Nullable<ElFile[]>,
fileList?: UploadFile[],
) => unknown
type AjaxEventListener = (e: ProgressEvent, file: ElFile) => unknown
export default defineComponent({
components: {
UploadDragger,
},
props: {
type: {
type: String,
default: '',
},
action: {
type: String,
required: true,
},
name: {
type: String,
default: 'file',
},
data: {
type: Object as PropType<Record<string, any>>,
default: () => null,
},
headers: {
type: Object as PropType<Nullable<Partial<Headers>>>,
default: () => null,
},
withCredentials: {
type: Boolean,
default: false,
},
multiple: {
type: Boolean as PropType<Nullable<boolean>>,
default: null,
},
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#Unique_file_type_specifiers
accept: {
type: String,
default: '',
},
onStart: {
type: Function as PropType<(file: File) => void>,
default: NOOP as (file: File) => void,
},
onProgress: {
type: Function as PropType<AjaxEventListener>,
default: NOOP as AjaxEventListener,
},
onSuccess: {
type: Function as PropType<AjaxEventListener>,
default: NOOP as AjaxEventListener,
},
onError: {
type: Function as PropType<AjaxEventListener>,
default: NOOP as AjaxEventListener,
},
beforeUpload: {
type: Function as PropType<
(file: File) => Promise<File | Blob> | boolean | unknown
>,
default: NOOP as (file: File) => void,
},
drag: {
type: Boolean,
default: false,
},
onPreview: {
type: Function as PropType<IFileHanlder>,
default: NOOP as IFileHanlder,
},
onRemove: {
type: Function as PropType<
(file: Nullable<FileList>, rawFile: ElFile) => void
>,
default: NOOP as (file: Nullable<FileList>, rawFile: ElFile) => void,
},
fileList: {
type: Array as PropType<UploadFile[]>,
default: () => [] as UploadFile[],
},
autoUpload: {
type: Boolean,
default: true,
},
listType: {
type: String as PropType<ListType>,
default: 'text',
},
httpRequest: {
type: Function as
| PropType<typeof ajax>
| PropType<(...args: unknown[]) => Promise<unknown>>,
default: () => ajax,
},
disabled: Boolean,
limit: {
type: Number as PropType<Nullable<number>>,
default: null,
},
onExceed: {
type: Function as PropType<
(files: FileList, fileList: UploadFile[]) => void
>,
default: NOOP,
},
},
setup(props) {
const reqs = ref({} as Indexable<XMLHttpRequest | Promise<any>>)
const mouseover = ref(false)
const inputRef = ref(null as Nullable<HTMLInputElement>)
function uploadFiles(files: FileList) {
if (props.limit && props.fileList.length + files.length > props.limit) {
props.onExceed(files, props.fileList)
return
}
let postFiles = Array.from(files)
if (!props.multiple) {
postFiles = postFiles.slice(0, 1)
}
if (postFiles.length === 0) {
return
}
postFiles.forEach(rawFile => {
props.onStart(rawFile)
if (props.autoUpload) upload(rawFile as ElFile)
})
}
function upload(rawFile: ElFile) {
inputRef.value.value = null
if (!props.beforeUpload) {
return post(rawFile)
}
const before = props.beforeUpload(rawFile)
if (before instanceof Promise) {
before
.then(processedFile => {
const fileType = Object.prototype.toString.call(processedFile)
if (fileType === '[object File]' || fileType === '[object Blob]') {
if (fileType === '[object Blob]') {
processedFile = new File([processedFile], rawFile.name, {
type: rawFile.type,
})
}
for (const p in rawFile) {
if (rawFile.hasOwnProperty(p)) {
processedFile[p] = rawFile[p]
}
}
post(processedFile)
} else {
post(rawFile)
}
})
.catch(() => {
props.onRemove(null, rawFile)
})
} else if (before !== false) {
post(rawFile)
} else {
props.onRemove(null, rawFile)
}
}
function abort(file) {
const _reqs = reqs.value
if (file) {
let uid = file
if (file.uid) uid = file.uid
if (_reqs[uid]) {
(_reqs[uid] as XMLHttpRequest).abort()
}
} else {
Object.keys(_reqs).forEach(uid => {
if (_reqs[uid]) (_reqs[uid] as XMLHttpRequest).abort()
delete _reqs[uid]
})
}
}
function post(rawFile: ElFile) {
const { uid } = rawFile
const options = {
headers: props.headers,
withCredentials: props.withCredentials,
file: rawFile,
data: props.data,
filename: props.name,
action: props.action,
onProgress: e => {
props.onProgress(e, rawFile)
},
onSuccess: res => {
props.onSuccess(res, rawFile)
delete reqs.value[uid]
},
onError: err => {
props.onError(err, rawFile)
delete reqs.value[uid]
},
}
const req = props.httpRequest(options)
reqs.value[uid] = req
if (req instanceof Promise) {
req.then(options.onSuccess, options.onError)
}
}
function handleChange(e: DragEvent) {
const files = (e.target as HTMLInputElement).files
if (!files) return
uploadFiles(files)
}
function handleClick() {
if (!props.disabled) {
inputRef.value.value = null
inputRef.value.click()
}
}
function handleKeydown() {
handleClick()
}
return {
reqs,
mouseover,
inputRef,
abort,
post,
handleChange,
handleClick,
handleKeydown,
upload,
uploadFiles,
}
},
})
</script>

View File

@ -0,0 +1,152 @@
import { ref, watch } from 'vue'
import { NOOP } from '@vue/shared'
// Inline types
import type { ListType, UploadFile, ElFile, ElUploadProgressEvent, IUseHandlersProps } from './upload'
type UploadRef = {
abort: (file: UploadFile) => void
upload: (file: ElFile) => void
}
// helpers
function getFile(rawFile: ElFile, uploadFiles: UploadFile[]) {
return uploadFiles.find(file => file.uid === rawFile.uid)
}
function genUid(seed: number) {
return Date.now() + seed
}
export default (props: IUseHandlersProps) => {
const uploadFiles = ref<UploadFile[]>([])
const uploadRef = ref<UploadRef>(null)
let tempIndex = 1
function abort(file: UploadFile) {
uploadRef.value.abort(file)
}
function clearFiles() {
uploadFiles.value = []
}
function handleError(err: Error, rawFile: ElFile) {
const file = getFile(rawFile, uploadFiles.value)
file.status = 'fail'
uploadFiles.value.splice(uploadFiles.value.indexOf(file), 1)
props.onError(err, file, uploadFiles.value)
props.onChange(file, uploadFiles.value)
}
function handleProgress(ev: ElUploadProgressEvent, rawFile: ElFile) {
const file = getFile(rawFile, uploadFiles.value)
props.onProgress(ev, file, uploadFiles.value)
file.status = 'uploading'
file.percentage = ev.percent || 0
}
function handleSuccess(res: any, rawFile: ElFile) {
const file = getFile(rawFile, uploadFiles.value)
if (file) {
file.status = 'success'
file.response = res
props.onSuccess(res, file, uploadFiles.value)
props.onChange(file, uploadFiles.value)
}
}
function handleStart(rawFile: ElFile) {
const uid = genUid(tempIndex++)
rawFile.uid = uid
const file: UploadFile = {
name: rawFile.name,
percentage: 0,
status: 'ready',
size: rawFile.size,
raw: rawFile,
uid,
}
if (props.listType === 'picture-card' || props.listType === 'picture') {
try {
file.url = URL.createObjectURL(rawFile)
} catch (err) {
console.error('[Element Error][Upload]', err)
props.onError(err, file, uploadFiles.value)
}
}
uploadFiles.value.push(file)
props.onChange(file, uploadFiles.value)
}
function handleRemove(file: UploadFile, raw: ElFile) {
if (raw) {
file = getFile(raw, uploadFiles.value)
}
const doRemove = () => {
abort(file)
const fileList = uploadFiles.value
fileList.splice(fileList.indexOf(file), 1)
props.onRemove(file, fileList)
}
if (!props.beforeRemove) {
doRemove()
} else if (typeof props.beforeRemove === 'function') {
const before = props.beforeRemove(file, uploadFiles.value)
if (before instanceof Promise) {
before.then(() => {
doRemove()
}).catch(NOOP)
} else if (before !== false) {
doRemove()
}
}
}
function submit() {
uploadFiles.value
.filter(file => file.status === 'ready')
.forEach(file => {
uploadRef.value.upload(file.raw)
})
}
watch(() => props.listType, (val: ListType) => {
if (val === 'picture-card' || val === 'picture') {
uploadFiles.value = uploadFiles.value.map(file => {
if (!file.url && file.raw) {
try {
file.url = URL.createObjectURL(file.raw)
} catch (err) {
props.onError(err, file, uploadFiles.value)
}
}
return file
})
}
})
watch(() => props.fileList, (fileList: UploadFile[]) => {
uploadFiles.value = fileList.map(file => {
file.uid = file.uid || genUid(tempIndex++)
file.status = file.status || 'success'
return file
})
}, {
immediate: true,
})
return {
clearFiles,
handleError,
handleProgress,
handleStart,
handleSuccess,
handleRemove,
submit,
uploadFiles,
uploadRef,
}
}

View File

@ -5,6 +5,7 @@ Upload files by clicking or drag-and-drop
### Click to upload files
:::demo Customize upload button type and text using `slot`. Set `limit` and `on-exceed` to limit the maximum number of uploads allowed and specify method when the limit is exceeded. Plus, you can abort removing a file in the `before-remove` hook.
```html
<el-upload
class="upload-demo"
@ -15,34 +16,55 @@ Upload files by clicking or drag-and-drop
multiple
:limit="3"
:on-exceed="handleExceed"
:file-list="fileList">
:file-list="fileList"
>
<el-button size="small" type="primary">Click to upload</el-button>
<div slot="tip" class="el-upload__tip">jpg/png files with a size less than 500kb</div>
<template #tip>
<div class="el-upload__tip">jpg/png files with a size less than 500kb</div>
</template>
</el-upload>
<script>
export default {
data() {
return {
fileList: [{name: 'food.jpeg', url: 'https://fuss10.elemecdn.com/3/63/4e7f3a15429bfda99bce42a18cdd1jpeg.jpeg?imageMogr2/thumbnail/360x360/format/webp/quality/100'}, {name: 'food2.jpeg', url: 'https://fuss10.elemecdn.com/3/63/4e7f3a15429bfda99bce42a18cdd1jpeg.jpeg?imageMogr2/thumbnail/360x360/format/webp/quality/100'}]
};
fileList: [
{
name: 'food.jpeg',
url:
'https://fuss10.elemecdn.com/3/63/4e7f3a15429bfda99bce42a18cdd1jpeg.jpeg?imageMogr2/thumbnail/360x360/format/webp/quality/100',
},
{
name: 'food2.jpeg',
url:
'https://fuss10.elemecdn.com/3/63/4e7f3a15429bfda99bce42a18cdd1jpeg.jpeg?imageMogr2/thumbnail/360x360/format/webp/quality/100',
},
],
}
},
methods: {
handleRemove(file, fileList) {
console.log(file, fileList);
console.log(file, fileList)
},
handlePreview(file) {
console.log(file);
console.log(file)
},
handleExceed(files, fileList) {
this.$message.warning(`The limit is 3, you selected ${files.length} files this time, add up to ${files.length + fileList.length} totally`);
this.$message.warning(
`The limit is 3, you selected ${
files.length
} files this time, add up to ${
files.length + fileList.length
} totally`,
)
},
beforeRemove(file, fileList) {
return this.$confirm(`Cancel the transfert of ${ file.name } ?`);
}
}
return this.$confirm(`Cancel the transfert of ${file.name} ?`)
},
},
}
</script>
```
:::
### User avatar upload
@ -50,14 +72,16 @@ Upload files by clicking or drag-and-drop
Use `before-upload` hook to limit the upload file format and size.
:::demo
```html
<el-upload
class="avatar-uploader"
action="https://jsonplaceholder.typicode.com/posts/"
:show-file-list="false"
:on-success="handleAvatarSuccess"
:before-upload="beforeAvatarUpload">
<img v-if="imageUrl" :src="imageUrl" class="avatar">
:before-upload="beforeAvatarUpload"
>
<img v-if="imageUrl" :src="imageUrl" class="avatar" />
<i v-else class="el-icon-plus avatar-uploader-icon"></i>
</el-upload>
@ -70,7 +94,7 @@ Use `before-upload` hook to limit the upload file format and size.
overflow: hidden;
}
.avatar-uploader .el-upload:hover {
border-color: #409EFF;
border-color: #409eff;
}
.avatar-uploader-icon {
font-size: 28px;
@ -91,29 +115,30 @@ Use `before-upload` hook to limit the upload file format and size.
export default {
data() {
return {
imageUrl: ''
};
imageUrl: '',
}
},
methods: {
handleAvatarSuccess(res, file) {
this.imageUrl = URL.createObjectURL(file.raw);
this.imageUrl = URL.createObjectURL(file.raw)
},
beforeAvatarUpload(file) {
const isJPG = file.type === 'image/jpeg';
const isLt2M = file.size / 1024 / 1024 < 2;
const isJPG = file.type === 'image/jpeg'
const isLt2M = file.size / 1024 / 1024 < 2
if (!isJPG) {
this.$message.error('Avatar picture must be JPG format!');
this.$message.error('Avatar picture must be JPG format!')
}
if (!isLt2M) {
this.$message.error('Avatar picture size can not exceed 2MB!');
this.$message.error('Avatar picture size can not exceed 2MB!')
}
return isJPG && isLt2M;
}
}
return isJPG && isLt2M
},
},
}
</script>
```
:::
### Photo Wall
@ -121,37 +146,40 @@ Use `before-upload` hook to limit the upload file format and size.
Use `list-type` to change the fileList style.
:::demo
```html
<el-upload
action="https://jsonplaceholder.typicode.com/posts/"
list-type="picture-card"
:on-preview="handlePictureCardPreview"
:on-remove="handleRemove">
:on-remove="handleRemove"
>
<i class="el-icon-plus"></i>
</el-upload>
<el-dialog :visible.sync="dialogVisible">
<img width="100%" :src="dialogImageUrl" alt="">
<img width="100%" :src="dialogImageUrl" alt="" />
</el-dialog>
<script>
export default {
data() {
return {
dialogImageUrl: '',
dialogVisible: false
};
dialogVisible: false,
}
},
methods: {
handleRemove(file, fileList) {
console.log(file, fileList);
console.log(file, fileList)
},
handlePictureCardPreview(file) {
this.dialogImageUrl = file.url;
this.dialogVisible = true;
}
}
this.dialogImageUrl = file.url
this.dialogVisible = true
},
},
}
</script>
```
:::
### Custom file thumbnail
@ -159,17 +187,15 @@ Use `list-type` to change the fileList style.
Use `scoped-slot` to change default thumbnail template.
:::demo
```html
<el-upload
action="#"
list-type="picture-card"
:auto-upload="false">
<i slot="default" class="el-icon-plus"></i>
<div slot="file" slot-scope="{file}">
<img
class="el-upload-list__item-thumbnail"
:src="file.url" alt=""
>
<el-upload action="#" list-type="picture-card" :auto-upload="false">
<template #default>
<i class="el-icon-plus"></i>
</template>
<template #file="{file}">
<div>
<img class="el-upload-list__item-thumbnail" :src="file.url" alt="" />
<span class="el-upload-list__item-actions">
<span
class="el-upload-list__item-preview"
@ -193,9 +219,10 @@ Use `scoped-slot` to change default thumbnail template.
</span>
</span>
</div>
</template>
</el-upload>
<el-dialog :visible.sync="dialogVisible">
<img width="100%" :src="dialogImageUrl" alt="">
<img width="100%" :src="dialogImageUrl" alt="" />
</el-dialog>
<script>
export default {
@ -203,29 +230,31 @@ Use `scoped-slot` to change default thumbnail template.
return {
dialogImageUrl: '',
dialogVisible: false,
disabled: false
};
disabled: false,
}
},
methods: {
handleRemove(file) {
console.log(file);
console.log(file)
},
handlePictureCardPreview(file) {
this.dialogImageUrl = file.url;
this.dialogVisible = true;
this.dialogImageUrl = file.url
this.dialogVisible = true
},
handleDownload(file) {
console.log(file);
}
}
console.log(file)
},
},
}
</script>
```
:::
### FileList with thumbnail
:::demo
```html
<el-upload
class="upload-demo"
@ -233,28 +262,45 @@ Use `scoped-slot` to change default thumbnail template.
:on-preview="handlePreview"
:on-remove="handleRemove"
:file-list="fileList"
list-type="picture">
list-type="picture"
>
<el-button size="small" type="primary">Click to upload</el-button>
<div slot="tip" class="el-upload__tip">jpg/png files with a size less than 500kb</div>
<template #tip>
<div class="el-upload__tip">
jpg/png files with a size less than 500kb
</div>
</template>
</el-upload>
<script>
export default {
data() {
return {
fileList: [{name: 'food.jpeg', url: 'https://fuss10.elemecdn.com/3/63/4e7f3a15429bfda99bce42a18cdd1jpeg.jpeg?imageMogr2/thumbnail/360x360/format/webp/quality/100'}, {name: 'food2.jpeg', url: 'https://fuss10.elemecdn.com/3/63/4e7f3a15429bfda99bce42a18cdd1jpeg.jpeg?imageMogr2/thumbnail/360x360/format/webp/quality/100'}]
};
fileList: [
{
name: 'food.jpeg',
url:
'https://fuss10.elemecdn.com/3/63/4e7f3a15429bfda99bce42a18cdd1jpeg.jpeg?imageMogr2/thumbnail/360x360/format/webp/quality/100',
},
{
name: 'food2.jpeg',
url:
'https://fuss10.elemecdn.com/3/63/4e7f3a15429bfda99bce42a18cdd1jpeg.jpeg?imageMogr2/thumbnail/360x360/format/webp/quality/100',
},
],
}
},
methods: {
handleRemove(file, fileList) {
console.log(file, fileList);
console.log(file, fileList)
},
handlePreview(file) {
console.log(file);
}
}
console.log(file)
},
},
}
</script>
```
:::
### File list control
@ -262,36 +308,48 @@ Use `scoped-slot` to change default thumbnail template.
Use `on-change` hook function to control upload file list
:::demo
```html
<el-upload
class="upload-demo"
action="https://jsonplaceholder.typicode.com/posts/"
:on-change="handleChange"
:file-list="fileList">
:file-list="fileList"
>
<el-button size="small" type="primary">Click to upload</el-button>
<div slot="tip" class="el-upload__tip">jpg/png files with a size less than 500kb</div>
<template #tip>
<div class="el-upload__tip">
jpg/png files with a size less than 500kb
</div>
</template>
</el-upload>
<script>
export default {
data() {
return {
fileList: [{
name: 'food.jpeg',
url: 'https://fuss10.elemecdn.com/3/63/4e7f3a15429bfda99bce42a18cdd1jpeg.jpeg?imageMogr2/thumbnail/360x360/format/webp/quality/100'
}, {
name: 'food2.jpeg',
url: 'https://fuss10.elemecdn.com/3/63/4e7f3a15429bfda99bce42a18cdd1jpeg.jpeg?imageMogr2/thumbnail/360x360/format/webp/quality/100'
}]
};
fileList: [
{
name: 'food.jpeg',
url:
'https://fuss10.elemecdn.com/3/63/4e7f3a15429bfda99bce42a18cdd1jpeg.jpeg?imageMogr2/thumbnail/360x360/format/webp/quality/100',
},
{
name: 'food2.jpeg',
url:
'https://fuss10.elemecdn.com/3/63/4e7f3a15429bfda99bce42a18cdd1jpeg.jpeg?imageMogr2/thumbnail/360x360/format/webp/quality/100',
},
],
}
},
methods: {
handleChange(file, fileList) {
this.fileList = fileList.slice(-3);
}
}
this.fileList = fileList.slice(-3)
},
},
}
</script>
```
:::
### Drag to upload
@ -299,6 +357,7 @@ Use `on-change` hook function to control upload file list
You can drag your file to a certain area to upload it.
:::demo
```html
<el-upload
class="upload-demo"
@ -307,37 +366,58 @@ You can drag your file to a certain area to upload it.
:on-preview="handlePreview"
:on-remove="handleRemove"
:file-list="fileList"
multiple>
multiple
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">Drop file here or <em>click to upload</em></div>
<div class="el-upload__tip" slot="tip">jpg/png files with a size less than 500kb</div>
<template #tip>
<div class="el-upload__tip">
jpg/png files with a size less than 500kb
</div>
</template>
</el-upload>
```
:::
### Manual upload
:::demo
```html
<el-upload
class="upload-demo"
ref="upload"
action="https://jsonplaceholder.typicode.com/posts/"
:auto-upload="false">
<el-button slot="trigger" size="small" type="primary">select file</el-button>
<el-button style="margin-left: 10px;" size="small" type="success" @click="submitUpload">upload to server</el-button>
<div class="el-upload__tip" slot="tip">jpg/png files with a size less than 500kb</div>
:auto-upload="false"
>
<template #trigger>
<el-button size="small" type="primary">select file</el-button>
</template>
<el-button
style="margin-left: 10px;"
size="small"
type="success"
@click="submitUpload"
>upload to server</el-button
>
<template #tip>
<div class="el-upload__tip">
jpg/png files with a size less than 500kb
</div>
</template>
</el-upload>
<script>
export default {
methods: {
submitUpload() {
this.$refs.upload.submit();
}
}
this.$refs.upload.submit()
},
},
}
</script>
```
:::
### Attributes