diff --git a/packages/varlet-vue2-ui/src/popup/Popup.jsx b/packages/varlet-vue2-ui/src/popup/Popup.jsx
index d8a0fcb..d4a4740 100644
--- a/packages/varlet-vue2-ui/src/popup/Popup.jsx
+++ b/packages/varlet-vue2-ui/src/popup/Popup.jsx
@@ -56,11 +56,12 @@ export default defineComponent({
},
renderContent() {
+ console.log(this.$attrs)
return (
diff --git a/packages/varlet-vue2-ui/src/uploader/Uploader.vue b/packages/varlet-vue2-ui/src/uploader/Uploader.vue
new file mode 100644
index 0000000..495c121
--- /dev/null
+++ b/packages/varlet-vue2-ui/src/uploader/Uploader.vue
@@ -0,0 +1,332 @@
+
+
+
+
+
+ {{ f.name || f.url }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/varlet-vue2-ui/src/uploader/__tests__/__snapshots__/index.spec.js.snap b/packages/varlet-vue2-ui/src/uploader/__tests__/__snapshots__/index.spec.js.snap
new file mode 100644
index 0000000..1ce6f5a
--- /dev/null
+++ b/packages/varlet-vue2-ui/src/uploader/__tests__/__snapshots__/index.spec.js.snap
@@ -0,0 +1,334 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`test uploader disabled 1`] = `
+""
+`;
+
+exports[`test uploader disabled 2`] = `
+""
+`;
+
+exports[`test uploader example 1`] = `
+"
+
基本使用
+
+
文件预览
+
+
+
+
+ https://varlet.gitee.io/varlet-ui/cat.jpg
+
+
+
+
+
+
+ https://www.runoob.com/try/demo_source/mov_bbb.mp4
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
上传状态
+
+
+
+
+ https://varlet.gitee.io/varlet-ui/cat.jpg
+
+
+
+
+
+
+ https://varlet.gitee.io/varlet-ui/cat.jpg
+
+
+
+
+
+
+ https://varlet.gitee.io/varlet-ui/cat.jpg
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
文件数量限制
+
+
文件大小限制
+
+
上传预处理
+
+
禁用
+
+
只读
+
+
删除前处理
+
+
+
+
+ https://varlet.gitee.io/varlet-ui/cat.jpg
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
自定义上传样式
+
+
字段校验
+
+
+
+
+ https://varlet.gitee.io/varlet-ui/cat.jpg
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
"
+`;
+
+exports[`test uploader length over maxlength in multiple mode 1`] = `
+""
+`;
+
+exports[`test uploader validation 1`] = `
+""
+`;
+
+exports[`test uploader validation 2`] = `
+""
+`;
diff --git a/packages/varlet-vue2-ui/src/uploader/__tests__/index.spec.js b/packages/varlet-vue2-ui/src/uploader/__tests__/index.spec.js
new file mode 100644
index 0000000..a1bf942
--- /dev/null
+++ b/packages/varlet-vue2-ui/src/uploader/__tests__/index.spec.js
@@ -0,0 +1,258 @@
+import example from '../example'
+import Uploader from '..'
+import VarUploader from '../Uploader'
+import ImagePreview from '../../image-preview'
+import { mount } from '@vue/test-utils'
+import Vue from 'vue'
+import { delay, mockFileReader, mockStubs } from '../../utils/jest'
+
+const createEvent = (filename) => {
+ return {
+ target: {
+ files: [new File([], filename)],
+ },
+ }
+}
+
+test('test uploader example', () => {
+ const wrapper = mount(example)
+ expect(wrapper.html()).toMatchSnapshot()
+ wrapper.destroy()
+})
+
+test('test uploader plugin', () => {
+ Vue.use(Uploader)
+ expect(Vue.component(Uploader.name)).toBeTruthy()
+})
+
+test('test uploader onAfterRead', async () => {
+ const onAfterRead = jest.fn()
+
+ const wrapper = mount(VarUploader, {
+ listeners: {
+ afterRead: onAfterRead,
+ },
+ })
+
+ await wrapper.vm.handleChange(createEvent('cat.png'))
+ expect(onAfterRead).toHaveBeenCalledTimes(1)
+
+ wrapper.destroy()
+})
+
+test('test uploader onBeforeRead', async () => {
+ const onAfterRead = jest.fn()
+
+ const wrapper = mount(VarUploader, {
+ listeners: {
+ afterRead: onAfterRead,
+ beforeRead: (file) => file.name.endsWith('jpg'),
+ },
+ })
+
+ await wrapper.vm.handleChange(createEvent('cat.png'))
+ expect(onAfterRead).toHaveBeenCalledTimes(0)
+
+ await wrapper.vm.handleChange(createEvent('cat.jpg'))
+ expect(onAfterRead).toHaveBeenCalledTimes(1)
+
+ wrapper.destroy()
+})
+
+test('test uploader preview', async () => {
+ const { mockRestore } = mockFileReader('data:image/png;base64,')
+ const { mockRestore: mockRestoreStubs } = mockStubs()
+ const onInput = jest.fn((value) => wrapper.setProps({ value }))
+
+ const wrapper = mount(VarUploader, {
+ propsData: {
+ value: [],
+ },
+ listeners: {
+ input: onInput,
+ },
+ })
+
+ await wrapper.vm.handleChange(createEvent('cat.jpg'))
+ await delay(16)
+ await wrapper.find('.var-uploader__file').trigger('click')
+ await delay(100)
+
+ expect(document.querySelector('.var-popup').style.display).toBe('')
+ ImagePreview.close()
+ await delay(300)
+
+ mockRestoreStubs()
+ wrapper.destroy()
+ mockRestore()
+})
+
+test('test uploader onOversize', async () => {
+ const onOversize = jest.fn()
+
+ const wrapper = mount(VarUploader, {
+ propsData: {
+ maxsize: -1,
+ },
+ listeners: {
+ oversize: onOversize,
+ },
+ })
+
+ await wrapper.vm.handleChange(createEvent('cat.png'))
+ expect(onOversize).toHaveBeenCalledTimes(1)
+
+ wrapper.destroy()
+})
+
+test('test uploader onRemove', async () => {
+ const { mockRestore } = mockFileReader('data:image/png;base64,')
+ const onInput = jest.fn((value) => wrapper.setProps({ value }))
+ const onRemove = jest.fn()
+
+ const wrapper = mount(VarUploader, {
+ propsData: {
+ value: [],
+ },
+ listeners: {
+ remove: onRemove,
+ input: onInput,
+ },
+ })
+
+ await wrapper.vm.handleChange(createEvent('cat.png'))
+ expect(onInput).toHaveBeenCalledTimes(1)
+
+ await wrapper.find('.var-uploader__file-close').trigger('click')
+ expect(onInput).toHaveBeenCalledTimes(2)
+ expect(onRemove).toHaveBeenCalledTimes(1)
+ expect(wrapper.vm.value).toStrictEqual([])
+
+ wrapper.destroy()
+ mockRestore()
+})
+
+test('test uploader onBeforeRemove', async () => {
+ const { mockRestore } = mockFileReader('data:image/png;base64,')
+ const onInput = jest.fn((value) => wrapper.setProps({ value }))
+ const onBeforeRemove = jest.fn(() => false)
+ const onRemove = jest.fn()
+
+ const wrapper = mount(VarUploader, {
+ propsData: {
+ value: [],
+ },
+ listeners: {
+ beforeRemove: onBeforeRemove,
+ remove: onRemove,
+ input: onInput,
+ },
+ })
+
+ await wrapper.vm.handleChange(createEvent('cat.png'))
+ expect(onInput).toHaveBeenCalledTimes(1)
+
+ await wrapper.find('.var-uploader__file-close').trigger('click')
+ expect(onBeforeRemove).toHaveBeenCalledTimes(1)
+ expect(onInput).toHaveBeenCalledTimes(1)
+ expect(onRemove).toHaveBeenCalledTimes(0)
+ expect(wrapper.vm.value).toHaveLength(1)
+
+ wrapper.destroy()
+ mockRestore()
+})
+
+test('test uploader validation', async () => {
+ const { mockRestore } = mockFileReader('data:image/png;base64,')
+ const onInput = jest.fn((value) => wrapper.setProps({ value }))
+
+ const wrapper = mount(VarUploader, {
+ propsData: {
+ value: [],
+ rules: [(v) => v.length >= 1 || '您至少上传一个'],
+ },
+ listeners: {
+ input: onInput,
+ },
+ })
+
+ wrapper.vm.validate()
+ await delay(16)
+ expect(wrapper.html()).toMatchSnapshot()
+ expect(wrapper.find('.var-form-details__message').text()).toBe('您至少上传一个')
+
+ await wrapper.vm.handleChange(createEvent('cat.png'))
+ await delay(16)
+ expect(onInput).toHaveBeenCalledTimes(1)
+ expect(wrapper.html()).toMatchSnapshot()
+ expect(wrapper.find('.var-form-details__message').exists()).toBeFalsy()
+
+ wrapper.vm.reset()
+ await delay(16)
+ expect(wrapper.vm.value).toStrictEqual([])
+
+ wrapper.destroy()
+ mockRestore()
+})
+
+test('test uploader disabled', async () => {
+ const { mockRestore } = mockFileReader('data:image/png;base64,')
+ const onInput = jest.fn((value) => wrapper.setProps({ value }))
+ const onRemove = jest.fn()
+ const onAfterRead = jest.fn()
+
+ const wrapper = mount(VarUploader, {
+ propsData: {
+ disabled: false,
+ value: [],
+ },
+ listeners: {
+ afterRead: onAfterRead,
+ remove: onRemove,
+ input: onInput,
+ },
+ })
+
+ expect(wrapper.html()).toMatchSnapshot()
+ await wrapper.vm.handleChange(createEvent('cat.png'))
+ expect(onAfterRead).toHaveBeenCalledTimes(1)
+
+ await wrapper.setProps({ disabled: true })
+ expect(wrapper.html()).toMatchSnapshot()
+ expect(wrapper.find('.var-uploader__action-input[disabled]').exists()).toBeTruthy()
+
+ await wrapper.find('.var-uploader__file-close').trigger('click')
+ expect(onRemove).toHaveBeenCalledTimes(0)
+
+ wrapper.destroy()
+ mockRestore()
+})
+
+test('test uploader length over maxlength in multiple mode', async () => {
+ const { mockRestore } = mockFileReader('data:image/png;base64,')
+ const onInput = jest.fn((value) => wrapper.setProps({ value }))
+
+ const wrapper = mount(VarUploader, {
+ propsData: {
+ maxlength: 1,
+ value: [],
+ },
+ listeners: {
+ input: onInput,
+ },
+ })
+
+ expect(wrapper.html()).toMatchSnapshot()
+
+ const event = {
+ target: {
+ files: [new File([], 'cat.png'), new File([], 'dog.png')],
+ },
+ }
+
+ await wrapper.vm.handleChange(event)
+ expect(wrapper.vm.value.length).toBe(1)
+
+ wrapper.destroy()
+ mockRestore()
+})
diff --git a/packages/varlet-vue2-ui/src/uploader/docs/en-US.md b/packages/varlet-vue2-ui/src/uploader/docs/en-US.md
new file mode 100644
index 0000000..9b89d62
--- /dev/null
+++ b/packages/varlet-vue2-ui/src/uploader/docs/en-US.md
@@ -0,0 +1,325 @@
+# Uploader
+
+### Intro
+It provides the ability to read files and preview pictures and videos.
+Get the file upload server by listening for `after-read` events.
+
+### Install
+
+```js
+import Vue from 'vue'
+import { Uploader } from '@varlet-vue2/ui'
+
+Vue.use(Uploader)
+```
+
+### Basic Usage
+
+```html
+
+```
+
+```js
+export default {
+ data: () => ({
+ files: []
+ }),
+ methods: {
+ handleAfterRead(file) {
+ console.log(file)
+ }
+ }
+}
+```
+
+### File Preview
+
+By analyzing the file URL suffix name to determine the file type, support image and video preview.
+
+```html
+
+```
+
+```js
+export default {
+ data: () => ({
+ files: [
+ {
+ url: 'https://varlet.gitee.io/varlet-ui/cat.jpg',
+ cover: 'https://varlet.gitee.io/varlet-ui/cat.jpg'
+ },
+ {
+ url: 'https://www.runoob.com/try/demo_source/mov_bbb.mp4',
+ cover: 'https://varlet.gitee.io/varlet-ui/cover.jpg'
+ }
+ ]
+ })
+}
+```
+
+### Upload State
+
+Three uploading states, `loading`, `success` and `error`, are provided, and tool functions are provided to quickly obtain files with corresponding states.
+
+```html
+
+```
+
+```js
+export default {
+ data: () => ({
+ files: [
+ {
+ url: 'https://varlet.gitee.io/varlet-ui/cat.jpg',
+ cover: 'https://varlet.gitee.io/varlet-ui/cat.jpg',
+ state: 'loading'
+ },
+ {
+ url: 'https://varlet.gitee.io/varlet-ui/cat.jpg',
+ cover: 'https://varlet.gitee.io/varlet-ui/cat.jpg',
+ state: 'success'
+ },
+ {
+ url: 'https://varlet.gitee.io/varlet-ui/cat.jpg',
+ cover: 'https://varlet.gitee.io/varlet-ui/cat.jpg',
+ state: 'error'
+ }
+ ]
+ }),
+ methods: {
+ handleAfterRead(file) {
+ file.state = 'loading'
+
+ setTimeout(() => {
+ file.state = 'success'
+ }, 1000)
+ }
+ }
+}
+```
+
+### File Maxlength
+
+```html
+
+```
+
+### File Size Limit
+
+If the limit is exceeded, the file will be blocked. You can get the file by listening for the `oversize` event.
+
+```html
+
+```
+
+```js
+import { Snackbar } from '@varlet-vue2/ui'
+
+export default {
+ data: () =>({
+ files: []
+ }),
+ methods: {
+ handleOversize() {
+ Snackbar.warning('file size exceeds limit')
+ }
+ }
+}
+```
+
+### Upload Preprocessing
+
+Operate on a file by registering a `before-read` event that returns a false value to prevent the file from being read.
+
+```html
+
+```
+
+```js
+import { Snackbar } from '@varlet-vue2/ui'
+
+export default {
+ data: () => ({
+ files: []
+ }),
+ methods: {
+ handleBeforeRead() {
+ if (file.file.size <= 1 * 1024 * 1024) {
+ Snackbar.success('the file is less than 1M and can be uploaded')
+ return true
+ } else {
+ Snackbar.warning('the file is larger than 1M and cannot be uploaded')
+ return false
+ }
+ }
+ }
+}
+```
+
+### Disabled
+
+```html
+
+```
+
+### Readonly
+
+```html
+
+```
+
+### Remove Preprocessing
+
+Before deleting the file, the `before-remove` event is triggered, and a falsy value is returned to prevent the delete operation.
+
+```html
+
+```
+
+```js
+import { Dialog } from '@varlet-vue2/ui'
+
+export default {
+ data: () => ({
+ files: [
+ {
+ url: 'https://varlet.gitee.io/varlet-ui/cat.jpg',
+ cover: 'https://varlet.gitee.io/varlet-ui/cat.jpg',
+ }
+ ]
+ }),
+ methods: {
+ async handleBeforeRemove() {
+ const action = await Dialog({
+ title: 'Delete or not?',
+ message: 'Cannot be withdrawn after deletion'
+ })
+
+ return action === 'confirm'
+ }
+ }
+}
+```
+
+
+### Customize upload styles
+
+```html
+
+ Upload
+
+```
+
+### Validate
+
+The values are validated by passing in an array of validator, If the validator returns `true`, the validation passes.
+Other values are converted to text as a user prompt.
+The second argument is a collection of utility functions that can quickly get a collection of files that match the state.
+
+```html
+
+```
+
+## API
+
+### Props
+
+| Prop | Description | Type | Default |
+| --- | --- | --- | --- |
+| `v-model` | File list | _VarFile[]_ | `[]` |
+| `accept` | Accepted file type, consistent with the native attribute | _string_ | `image/*` |
+| `capture` | Get the file, the same as the native property | _string_ | `-` |
+| `multiple` | Whether to select multiple files | _boolean_ | `false` |
+| `readonly` | Whether the readonly | _boolean_ | `false` |
+| `disabled` | Whether the disabled | _boolean_ | `false` |
+| `removable` | Whether the removable | _boolean_ | `true` |
+| `maxlength` | Maximum number of files | _string \| number_ | `-` |
+| `maxsize` | Maximum file size | _string \| number_ | `-` |
+| `previewed` | Whether to allow preview | _boolean_ | `true` |
+| `ripple` | Whether to open ripple | _boolean_ | `true` |
+| `validate-trigger` | Timing to trigger validation, The optional value is `onChange` `onRemove` | _ValidateTriggers[]_ | `['onChange', 'onRemove']` |
+| `rules` | The validation rules,Returns `true` to indicate that the validation passed,The remaining values are converted to text as user prompts | _Array<(v: VarFile, u: VarFileUtils) => any>_ | `-` |
+
+### VarFile
+
+| Prop | Description | Type | Default |
+| --- | --- | --- | --- |
+| `file` | Native file | _File_ | `-` |
+| `name` | File name | _string_ | `-` |
+| `url` | File url | _string_ | `-` |
+| `cover` | File cover image | _string_ | `-` |
+| `fit` | Cover image fill mode, Optional value is `fill` `contain` `cover` `none` `scale-down` | _string_ | `-` |
+| `state` | File upload state, Optional value is `loading` `success` `error` | _string_ | `-` |
+
+### VarFileUtils
+
+| Method | Description | Arguments | Return |
+| --- | --- | --- | --- |
+| `getLoading` | Gets a collection of files for `state` is `loading` | `VarFile[]` | `VarFile[]` |
+| `getSuccess` | Gets a collection of files for `state` is `success` | `VarFile[]` | `VarFile[]` |
+| `getError` | Gets a collection of files for `state` is `error` | `VarFile[]` | `VarFile[]` |
+
+### Methods
+
+| Method | Description | Arguments | Return |
+| --- | --- | --- | --- |
+| `getLoading` | Gets a collection of files for `state` is `loading` | `VarFile[]` | `VarFile[]` |
+| `getSuccess` | Gets a collection of files for `state` is `success` | `VarFile[]` | `VarFile[]` |
+| `getError` | Gets a collection of files for `state` is `error` | `VarFile[]` | `VarFile[]` |
+| `validate` | Trigger validate | `-` | `valid: Promise` |
+| `resetValidation` | Clearing validate messages | `-` | `-` |
+| `reset` | Clear the value of the binding(set to `[]`)and validate messages | `-` | `-` |
+
+### Events
+
+| Event | Description | Arguments |
+| --- | --- | --- |
+| `before-read` | Trigger returns a false value before a file is read to prevent the file from being read(support promise) | `file: VarFile` |
+| `after-read` | Triggered after the file is read | `file: VarFile` |
+| `oversize` | Triggered when the file size limit is exceeded | `file: VarFile` |
+| `before-remove` | Triggered before file deletion, return false value to prevent file deletion (support promise) | `file: VarFile` |
+| `remove` | Triggered when deleting a file. There is a true value to prevent deleting a file (support promise) | `file: VarFile` |
+
+### Slots
+
+| Slot | Description | Arguments |
+| --- | --- | --- |
+| `default` | Upload action content | `-` |
+
+### Style Variables
+
+Here are the CSS variables used by the component, Styles can be customized using [StyleProvider](#/en-US/style-provider)
+
+| Variable | Default |
+| --- | --- |
+| `--uploader-action-background` | `#f7f8fa` |
+| `--uploader-action-icon-color` | `#888` |
+| `--uploader-action-icon-size` | `24px` |
+| `--uploader-action-margin` | `0 10px 10px 0` |
+| `--uploader-file-size` | `80px` |
+| `--uploader-file-margin` | `0 10px 10px 0` |
+| `--uploader-file-name-background` | `#f7f8fa` |
+| `--uploader-file-name-color` | `#888` |
+| `--uploader-file-name-font-size` | `12px` |
+| `--uploader-file-name-padding` | `10px` |
+| `--uploader-file-text-align` | `center` |
+| `--uploader-file-close-background` | `rgba(0, 0, 0, 0.3)` |
+| `--uploader-file-close-size` | `24px` |
+| `--uploader-file-close-icon-font-size` | `14px` |
+| `--uploader-file-close-icon-color` | `#fff` |
+| `--uploader-file-cover-fit` | `cover` |
+| `--uploader-file-cover-background` | `#f7f8fa` |
+| `--uploader-preview-video-width` | `100vw` |
+| `--uploader-preview-video-height` | `100vw` |
+| `--uploader-file-indicator-height` | `4px` |
+| `--uploader-file-indicator-normal-color` | `var(--color-disabled)` |
+| `--uploader-file-indicator-success-color` | `var(--color-success)` |
+| `--uploader-file-indicator-error-color` | `var(--color-danger)` |
+| `--uploader-disabled-color` | `#ddd`
+| `--uploader-disabled-text-color` | `var(--color-text-disabled)` |
+| `--uploader-loading-background` | `linear-gradient(90deg, hsla(0, 0%, 100%, 0), hsla(0, 0%, 100%, 0.3), hsla(0, 0%, 100%, 0))` |
\ No newline at end of file
diff --git a/packages/varlet-vue2-ui/src/uploader/docs/zh-CN.md b/packages/varlet-vue2-ui/src/uploader/docs/zh-CN.md
new file mode 100644
index 0000000..0f64f6e
--- /dev/null
+++ b/packages/varlet-vue2-ui/src/uploader/docs/zh-CN.md
@@ -0,0 +1,322 @@
+# 文件上传
+
+### 介绍
+提供了文件读取、图片/视频预览能力。
+通过监听 `after-read` 事件获取文件上传服务器。
+
+### 引入
+
+```js
+import Vue from 'vue'
+import { Uploader } from '@varlet-vue2/ui'
+
+Vue.use(Uploader)
+```
+
+### 基本使用
+
+```html
+
+```
+
+```js
+export default {
+ data: () => ({
+ files: []
+ }),
+ methods: {
+ handleAfterRead(file) {
+ console.log(file)
+ }
+ }
+}
+```
+
+### 文件预览
+
+通过分析文件的 url 后缀名判断文件类型,支持图片和视频预览。
+
+```html
+
+```
+
+```js
+export default {
+ data: () => ({
+ files: [
+ {
+ url: 'https://varlet.gitee.io/varlet-ui/cat.jpg',
+ cover: 'https://varlet.gitee.io/varlet-ui/cat.jpg'
+ },
+ {
+ url: 'https://www.runoob.com/try/demo_source/mov_bbb.mp4',
+ cover: 'https://varlet.gitee.io/varlet-ui/cover.jpg'
+ }
+ ]
+ })
+}
+```
+
+### 上传状态
+
+提供了 `loading`、 `success`、 `error` 三种上传状态,并提供了工具函数快速获取对应状态的文件。
+
+```html
+
+```
+
+```js
+export default {
+ data: () => ({
+ files: [
+ {
+ url: 'https://varlet.gitee.io/varlet-ui/cat.jpg',
+ cover: 'https://varlet.gitee.io/varlet-ui/cat.jpg',
+ state: 'loading'
+ },
+ {
+ url: 'https://varlet.gitee.io/varlet-ui/cat.jpg',
+ cover: 'https://varlet.gitee.io/varlet-ui/cat.jpg',
+ state: 'success'
+ },
+ {
+ url: 'https://varlet.gitee.io/varlet-ui/cat.jpg',
+ cover: 'https://varlet.gitee.io/varlet-ui/cat.jpg',
+ state: 'error'
+ }
+ ]
+ }),
+ methods: {
+ handleAfterRead(file) {
+ file.state = 'loading'
+
+ setTimeout(() => {
+ file.state = 'success'
+ }, 1000)
+ }
+ }
+}
+```
+
+### 文件数量限制
+
+```html
+
+```
+
+### 文件大小限制
+
+超过限制会被阻止读取,可以通过监听 `oversize` 事件获取文件。
+
+```html
+
+```
+
+```js
+import { Snackbar } from '@varlet-vue2/ui'
+
+export default {
+ data: () =>({
+ files: []
+ }),
+ methods: {
+ handleOversize() {
+ Snackbar.warning('文件大小超出限制')
+ }
+ }
+}
+```
+
+### 上传预处理
+
+通过注册 `before-read` 事件对文件进行操作,返回假值阻止文件读取。
+
+```html
+
+```
+
+```js
+import { Snackbar } from '@varlet-vue2/ui'
+
+export default {
+ data: () => ({
+ files: []
+ }),
+ methods: {
+ handleBeforeRead() {
+ if (file.file.size <= 1 * 1024 * 1024) {
+ Snackbar.success('文件小于1M,可以上传')
+ return true
+ } else {
+ Snackbar.warning('文件大于1M,不能上传')
+ return false
+ }
+ }
+ }
+}
+```
+
+### 禁用
+
+```html
+
+```
+
+### 只读
+
+```html
+
+```
+
+### 删除前处理
+
+删除文件之前会触发 `before-remove` 事件,返回假值阻止删除操作。
+
+```html
+
+```
+
+```js
+import { Dialog } from '@varlet-vue2/ui'
+
+export default {
+ data: () => ({
+ files: [
+ {
+ url: 'https://varlet.gitee.io/varlet-ui/cat.jpg',
+ cover: 'https://varlet.gitee.io/varlet-ui/cat.jpg',
+ }
+ ]
+ }),
+ methods: {
+ async handleBeforeRemove() {
+ const action = await Dialog({
+ title: '是否删除?',
+ message: '删除后无法撤回'
+ })
+
+ return action === 'confirm'
+ }
+ }
+}
+```
+
+### 自定义上传样式
+
+```html
+
+ 上传
+
+```
+
+### 字段校验
+
+通过传入一个校验器数组可以对值进行校验,校验器返回 `true` 则为校验通过。
+以外的值将转换为文本作为用户提示。
+第二个参数是一个工具函数集合,可以快速获取符合状态的文件集合。
+
+```html
+
+```
+
+## API
+
+### 属性
+
+| 参数 | 说明 | 类型 | 默认值 |
+| --- | --- | --- | --- |
+| `v-model` | 文件列表 | _VarFile[]_ | `[]` |
+| `accept` | 接受的文件类型,与原生属性一致 | _string_ | `image/*` |
+| `capture` | 获取文件方式,与原生属性一致 | _string_ | `-` |
+| `multiple` | 是否多选文件 | _boolean_ | `false` |
+| `readonly` | 是否只读 | _boolean_ | `false` |
+| `disabled` | 是否禁用 | _boolean_ | `false` |
+| `removable` | 是否可以删除 | _boolean_ | `true` |
+| `maxlength` | 最大文件个数 | _string \| number_ | `-` |
+| `maxsize` | 最大文件大小 | _string \| number_ | `-` |
+| `previewed` | 是否允许预览 | _boolean_ | `true` |
+| `ripple` | 是否开启水波纹 | _boolean_ | `true` |
+| `validate-trigger` | 触发验证的时机, 可选值为 `onChange` `onRemove` | _ValidateTriggers[]_ | `['onChange', 'onRemove']` |
+| `rules` | 验证规则,返回 `true` 表示验证通过,其余的值则转换为文本作为用户提示 | _Array<(v: VarFile, u: VarFileUtils) => any>_ | `-` |
+
+### VarFile
+
+| 参数 | 说明 | 类型 | 默认值 |
+| --- | --- | --- | --- |
+| `file` | 原生文件 | _File_ | `-` |
+| `name` | 文件名 | _string_ | `-` |
+| `url` | 文件地址 | _string_ | `-` |
+| `cover` | 封面图 | _string_ | `-` |
+| `fit` | 封面图填充模式,可选值为 `fill` `contain` `cover` `none` `scale-down` | _string_ | `-` |
+| `state` | 文件上传状态,可选值为 `loading` `success` `error` | _string_ | `-` |
+
+### VarFileUtils
+
+| 方法名 | 说明 | 参数 | 返回值 |
+| --- | --- | --- | --- |
+| `getLoading` | 获取 `state` 等于 `loading` 的文件集合| `VarFile[]` | `VarFile[]` |
+| `getSuccess` | 获取 `state` 等于 `success` 的文件集合| `VarFile[]` | `VarFile[]` |
+| `getError` | 获取 `state` 等于 `error` 的文件集合 | `VarFile[]` | `VarFile[]` |
+
+### 方法
+
+| 方法名 | 说明 | 参数 | 返回值 |
+| --- | --- | --- | --- |
+| `getLoading` | 获取 `state` 等于 `loading` 的文件集合| `VarFile[]` | `VarFile[]` |
+| `getSuccess` | 获取 `state` 等于 `success` 的文件集合| `VarFile[]` | `VarFile[]` |
+| `getError` | 获取 `state` 等于 `error` 的文件集合 | `VarFile[]` | `VarFile[]` |
+| `validate` | 触发校验 | `-` | `valid: Promise` |
+| `resetValidation` | 清空校验信息 | `-` | `-` |
+| `reset` | 清空绑定的值(设置为 `[]`)和校验信息 | `-` | `-` |
+
+### 事件
+
+| 事件名 | 说明 | 参数 |
+| --- | --- | --- |
+| `before-read` | 文件读取前触发,返回假值阻止文件读取(支持 promise) | `file: VarFile` |
+| `after-read` | 文件读取后触发 | `file: VarFile` |
+| `oversize` | 文件超过限制大小时触发 | `file: VarFile` |
+| `before-remove` | 文件删除前触发,返回假值阻止文件删除(支持 promise) | `file: VarFile` |
+| `remove` | 文件删除时触发 | `file: VarFile` |
+
+### 插槽
+
+| 插槽名 | 说明 | 参数 |
+| --- | --- | --- |
+| `default` | 上传按钮内容 | `-` |
+
+### 样式变量
+
+以下为组件使用的 css 变量,可以使用 [StyleProvider 组件](#/zh-CN/style-provider)进行样式定制
+
+| 变量名 | 默认值 |
+| --- | --- |
+| `--uploader-action-background` | `#f7f8fa` |
+| `--uploader-action-icon-color` | `#888` |
+| `--uploader-action-icon-size` | `24px` |
+| `--uploader-action-margin` | `0 10px 10px 0` |
+| `--uploader-file-size` | `80px` |
+| `--uploader-file-margin` | `0 10px 10px 0` |
+| `--uploader-file-name-background` | `#f7f8fa` |
+| `--uploader-file-name-color` | `#888` |
+| `--uploader-file-name-font-size` | `12px` |
+| `--uploader-file-name-padding` | `10px` |
+| `--uploader-file-text-align` | `center` |
+| `--uploader-file-close-background` | `rgba(0, 0, 0, 0.3)` |
+| `--uploader-file-close-size` | `24px` |
+| `--uploader-file-close-icon-font-size` | `14px` |
+| `--uploader-file-close-icon-color` | `#fff` |
+| `--uploader-file-cover-fit` | `cover` |
+| `--uploader-file-cover-background` | `#f7f8fa` |
+| `--uploader-preview-video-width` | `100vw` |
+| `--uploader-preview-video-height` | `100vw` |
+| `--uploader-file-indicator-height` | `4px` |
+| `--uploader-file-indicator-normal-color` | `var(--color-disabled)` |
+| `--uploader-file-indicator-success-color` | `var(--color-success)` |
+| `--uploader-file-indicator-error-color` | `var(--color-danger)` |
+| `--uploader-disabled-color` | `#ddd` |
+| `--uploader-disabled-text-color` | `var(--color-text-disabled)` |
+| `--uploader-loading-background` | `linear-gradient(90deg, hsla(0, 0%, 100%, 0), hsla(0, 0%, 100%, 0.3), hsla(0, 0%, 100%, 0))` |
diff --git a/packages/varlet-vue2-ui/src/uploader/example/index.vue b/packages/varlet-vue2-ui/src/uploader/example/index.vue
new file mode 100644
index 0000000..622d0e6
--- /dev/null
+++ b/packages/varlet-vue2-ui/src/uploader/example/index.vue
@@ -0,0 +1,165 @@
+
+
+
{{ pack.basicUsage }}
+
+
+
{{ pack.preview }}
+
+
+
{{ pack.state }}
+
+
+
{{ pack.maxlength }}
+
+
+
{{ pack.maxsize }}
+
+
+
{{ pack.beforeRead }}
+
+
+
{{ pack.disabled }}
+
+
+
{{ pack.readonly }}
+
+
+
{{ pack.beforeRemove }}
+
+
+
{{ pack.style }}
+
+ {{ pack.upload }}
+
+
+
{{ pack.validate }}
+
+
+
+
+
+
+
+
+
diff --git a/packages/varlet-vue2-ui/src/uploader/example/locale/en-US.ts b/packages/varlet-vue2-ui/src/uploader/example/locale/en-US.ts
new file mode 100644
index 0000000..73582fd
--- /dev/null
+++ b/packages/varlet-vue2-ui/src/uploader/example/locale/en-US.ts
@@ -0,0 +1,17 @@
+export default {
+ basicUsage: 'Basic Usage',
+ preview: 'File Preview',
+ state: 'Upload State',
+ maxlength: 'File Maxlength',
+ maxsize: 'File Size Limit',
+ beforeRead: 'Upload Preprocessing',
+ disabled: 'Disabled',
+ readonly: 'Readonly',
+ validate: 'Validate',
+ validateMessage: 'There is a file that failed to upload',
+ style: 'Customize upload styles',
+ upload: 'Upload',
+ beforeRemove: 'Remove Preprocessing',
+ removeTitle: 'Delete or not?',
+ removeMessage: 'Cannot be withdrawn after deletion',
+}
diff --git a/packages/varlet-vue2-ui/src/uploader/example/locale/index.ts b/packages/varlet-vue2-ui/src/uploader/example/locale/index.ts
new file mode 100644
index 0000000..d2e375e
--- /dev/null
+++ b/packages/varlet-vue2-ui/src/uploader/example/locale/index.ts
@@ -0,0 +1,23 @@
+// lib
+import _zhCN from '../../../locale/zh-CN'
+import _enCN from '../../../locale/en-US'
+// mobile example doc
+import zhCN from './zh-CN'
+import enUS from './en-US'
+import { useLocale, add as _add, use as _use } from '../../../locale'
+
+const { add, use: exampleUse, pack, packs, merge } = useLocale()
+
+const use = (lang: string) => {
+ _use(lang)
+ exampleUse(lang)
+}
+
+export { add, pack, packs, merge, use }
+
+// lib
+_add('zh-CN', _zhCN)
+_add('en-US', _enCN)
+// mobile example doc
+add('zh-CN', zhCN as any)
+add('en-US', enUS as any)
diff --git a/packages/varlet-vue2-ui/src/uploader/example/locale/zh-CN.ts b/packages/varlet-vue2-ui/src/uploader/example/locale/zh-CN.ts
new file mode 100644
index 0000000..d396820
--- /dev/null
+++ b/packages/varlet-vue2-ui/src/uploader/example/locale/zh-CN.ts
@@ -0,0 +1,17 @@
+export default {
+ basicUsage: '基本使用',
+ preview: '文件预览',
+ state: '上传状态',
+ maxlength: '文件数量限制',
+ maxsize: '文件大小限制',
+ beforeRead: '上传预处理',
+ disabled: '禁用',
+ readonly: '只读',
+ validate: '字段校验',
+ validateMessage: '存在上传失败的文件',
+ style: '自定义上传样式',
+ upload: '上传',
+ beforeRemove: '删除前处理',
+ removeTitle: '是否删除?',
+ removeMessage: '删除后无法撤回',
+}
diff --git a/packages/varlet-vue2-ui/src/uploader/index.ts b/packages/varlet-vue2-ui/src/uploader/index.ts
new file mode 100644
index 0000000..d0488c0
--- /dev/null
+++ b/packages/varlet-vue2-ui/src/uploader/index.ts
@@ -0,0 +1,10 @@
+import type { VueConstructor } from 'vue'
+import Uploader from './Uploader.vue'
+
+Uploader.install = function (app: VueConstructor) {
+ app.component(Uploader.name, Uploader)
+}
+
+export const _UploaderComponent = Uploader
+
+export default Uploader
diff --git a/packages/varlet-vue2-ui/src/uploader/props.ts b/packages/varlet-vue2-ui/src/uploader/props.ts
new file mode 100644
index 0000000..134b726
--- /dev/null
+++ b/packages/varlet-vue2-ui/src/uploader/props.ts
@@ -0,0 +1,65 @@
+import type { PropType } from 'vue'
+
+export interface VarFile {
+ id: number
+ file?: File
+ name?: string
+ url?: string
+ cover?: string
+ fit?: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down'
+ state?: 'loading' | 'success' | 'error'
+}
+
+export type ValidateTriggers = 'onChange' | 'onRemove'
+
+export const props = {
+ value: {
+ type: Array as PropType,
+ default: () => [],
+ },
+ accept: {
+ type: String,
+ default: 'image/*',
+ },
+ capture: {
+ type: [String, Boolean] as PropType,
+ default: undefined,
+ },
+ multiple: {
+ type: Boolean,
+ default: false,
+ },
+ readonly: {
+ type: Boolean,
+ default: false,
+ },
+ disabled: {
+ type: Boolean,
+ default: false,
+ },
+ removable: {
+ type: Boolean,
+ default: true,
+ },
+ maxlength: {
+ type: [Number, String],
+ },
+ maxsize: {
+ type: [Number, String],
+ },
+ previewed: {
+ type: Boolean,
+ default: true,
+ },
+ ripple: {
+ type: Boolean,
+ default: true,
+ },
+ validateTrigger: {
+ type: Array as PropType>,
+ default: () => ['onChange', 'onRemove'],
+ },
+ rules: {
+ type: Array as PropType any>>,
+ },
+}
diff --git a/packages/varlet-vue2-ui/src/uploader/uploader.less b/packages/varlet-vue2-ui/src/uploader/uploader.less
new file mode 100644
index 0000000..a533f6b
--- /dev/null
+++ b/packages/varlet-vue2-ui/src/uploader/uploader.less
@@ -0,0 +1,219 @@
+@uploader-action-background: #f7f8fa;
+@uploader-action-icon-color: #888;
+@uploader-action-icon-size: 24px;
+@uploader-action-margin: 0 10px 10px 0;
+@uploader-file-size: 80px;
+@uploader-file-margin: 0 10px 10px 0;
+@uploader-file-name-background: #f7f8fa;
+@uploader-file-name-color: #888;
+@uploader-file-name-font-size: 12px;
+@uploader-file-name-padding: 10px;
+@uploader-file-text-align: center;
+@uploader-file-close-background: rgba(0, 0, 0, 0.3);
+@uploader-file-close-size: 24px;
+@uploader-file-close-icon-font-size: 14px;
+@uploader-file-close-icon-color: #fff;
+@uploader-file-cover-fit: cover;
+@uploader-file-cover-background: #f7f8fa;
+@uploader-preview-video-width: 100vw;
+@uploader-preview-video-height: 100vw;
+@uploader-file-indicator-height: 4px;
+@uploader-file-indicator-normal-color: var(--color-disabled);
+@uploader-file-indicator-success-color: var(--color-success);
+@uploader-file-indicator-error-color: var(--color-danger);
+@uploader-disabled-color: var(--color-disabled);
+@uploader-disabled-text-color: var(--color-text-disabled);
+@uploader-loading-background: linear-gradient(
+ 90deg,
+ hsla(0, 0%, 100%, 0),
+ hsla(0, 0%, 100%, 0.3),
+ hsla(0, 0%, 100%, 0)
+);
+
+:root {
+ --uploader-action-background: @uploader-action-background;
+ --uploader-action-icon-color: @uploader-action-icon-color;
+ --uploader-action-icon-size: @uploader-action-icon-size;
+ --uploader-action-margin: @uploader-action-margin;
+ --uploader-file-size: @uploader-file-size;
+ --uploader-file-margin: @uploader-file-margin;
+ --uploader-file-name-background: @uploader-file-name-background;
+ --uploader-file-name-color: @uploader-file-name-color;
+ --uploader-file-name-font-size: @uploader-file-name-font-size;
+ --uploader-file-name-padding: @uploader-file-name-padding;
+ --uploader-file-text-align: @uploader-file-text-align;
+ --uploader-file-close-background: @uploader-file-close-background;
+ --uploader-file-close-size: @uploader-file-close-size;
+ --uploader-file-close-icon-font-size: @uploader-file-close-icon-font-size;
+ --uploader-file-close-icon-color: @uploader-file-close-icon-color;
+ --uploader-file-cover-fit: @uploader-file-cover-fit;
+ --uploader-file-cover-background: @uploader-file-cover-background;
+ --uploader-preview-video-width: @uploader-preview-video-width;
+ --uploader-preview-video-height: @uploader-preview-video-height;
+ --uploader-file-indicator-height: @uploader-file-indicator-height;
+ --uploader-file-indicator-normal-color: @uploader-file-indicator-normal-color;
+ --uploader-file-indicator-success-color: @uploader-file-indicator-success-color;
+ --uploader-file-indicator-error-color: @uploader-file-indicator-error-color;
+ --uploader-disabled-color: @uploader-disabled-color;
+ --uploader-disabled-text-color: @uploader-disabled-text-color;
+ --uploader-loading-background: @uploader-loading-background;
+}
+
+@keyframes var-uploader-loading-animation {
+ from {
+ transform: translateX(-100%);
+ }
+
+ to {
+ transform: translateX(100%);
+ }
+}
+
+.var-uploader {
+ width: 100%;
+
+ &__file-list {
+ width: 100%;
+ display: flex;
+ flex-wrap: wrap;
+ }
+
+ &__file {
+ position: relative;
+ width: var(--uploader-file-size);
+ height: var(--uploader-file-size);
+ margin: var(--uploader-file-margin);
+ overflow: hidden;
+ cursor: pointer;
+ -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
+ }
+
+ &__file-name {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ line-height: calc(var(--uploader-file-size) - var(--uploader-file-name-padding) * 2);
+ font-size: var(--uploader-file-name-font-size);
+ z-index: 0;
+ background: var(--uploader-file-name-background);
+ padding: var(--uploader-file-name-padding);
+ color: var(--uploader-file-name-color);
+ text-align: var(--uploader-file-text-align);
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ user-select: none;
+ transition: background-color 0.25s;
+ }
+
+ &__file-cover {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 1;
+ background: var(--uploader-file-cover-background);
+ object-fit: var(--uploader-file-cover-fit);
+ pointer-events: none;
+ transition: background-color 0.25s;
+ }
+
+ &__file-close {
+ position: absolute;
+ top: 0;
+ right: 0;
+ z-index: 2;
+ border-top: var(--uploader-file-close-size) solid var(--uploader-file-close-background);
+ border-left: var(--uploader-file-close-size) solid transparent;
+ }
+
+ &__file-close-icon[var-uploader-cover] {
+ position: absolute;
+ top: calc(-1 * var(--uploader-file-close-size));
+ right: 0;
+ font-size: var(--uploader-file-close-icon-font-size);
+ color: var(--uploader-file-close-icon-color);
+ }
+
+ &__file-indicator {
+ position: absolute;
+ left: 0;
+ bottom: 0;
+ z-index: 2;
+ width: 100%;
+ height: var(--uploader-file-indicator-height);
+ transition: all 0.25s;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ background: var(--uploader-file-indicator-normal-color);
+ }
+
+ &__action {
+ position: relative;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ color: var(--uploader-action-icon-color);
+ width: var(--uploader-file-size);
+ height: var(--uploader-file-size);
+ background: var(--uploader-action-background);
+ margin: var(--uploader-action-margin);
+ transition: background-color 0.25s;
+ }
+
+ &__action-icon[var-uploader-cover] {
+ font-size: var(--uploader-action-icon-size);
+ }
+
+ &__action-input {
+ position: absolute;
+ z-index: 1;
+ width: 100%;
+ height: 100%;
+ top: 0;
+ left: 0;
+ opacity: 0;
+ cursor: pointer;
+ -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
+ }
+
+ &__preview[var-uploader-cover] {
+ background: #000;
+ box-shadow: none;
+ }
+
+ &__preview-video {
+ width: var(--uploader-preview-video-width);
+ height: var(--uploader-preview-video-height);
+ }
+
+ &--loading {
+ &::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 1;
+ animation: var-uploader-loading-animation 0.6s infinite;
+ background: var(--uploader-loading-background);
+ transition: background-color 0.25s;
+ }
+ }
+
+ &--success {
+ background: var(--uploader-file-indicator-success-color);
+ }
+
+ &--error {
+ background: var(--uploader-file-indicator-error-color);
+ }
+
+ &--disabled {
+ background: var(--uploader-disabled-color);
+ color: var(--uploader-disabled-text-color);
+ }
+}
diff --git a/packages/varlet-vue2-ui/types/index.d.ts b/packages/varlet-vue2-ui/types/index.d.ts
index e69e73e..78ed214 100644
--- a/packages/varlet-vue2-ui/types/index.d.ts
+++ b/packages/varlet-vue2-ui/types/index.d.ts
@@ -57,5 +57,6 @@ export * from './list'
export * from './imagePreview'
export * from './collapse'
export * from './collapseItem'
+export * from './uploader'
export * from './varComponent'
export * from './varDirective'
diff --git a/packages/varlet-vue2-ui/types/uploader.d.ts b/packages/varlet-vue2-ui/types/uploader.d.ts
new file mode 100644
index 0000000..259659b
--- /dev/null
+++ b/packages/varlet-vue2-ui/types/uploader.d.ts
@@ -0,0 +1,51 @@
+import { VarComponent } from './varComponent'
+
+export interface VarFile {
+ file?: File
+ name?: string
+ url?: string
+ cover?: string
+ fit?: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down'
+ state?: 'loading' | 'success' | 'error'
+}
+
+export type UploaderValidateTriggers = 'onChange' | 'onRemove'
+
+interface UploaderProps {
+ value?: VarFile[]
+ accept?: string
+ capture?: boolean | 'user' | 'environment'
+ multiple?: boolean
+ readonly?: boolean
+ disabled?: boolean
+ removable?: boolean
+ maxlength?: string | number
+ maxsize?: string | number
+ previewed?: boolean
+ ripple?: boolean
+ validateTrigger?: Array
+ rules?: Array<(v: VarFile) => any>
+ onBeforeRead?: (file: VarFile) => Promise | boolean
+ onAfterRead?: (file: VarFile) => any
+ onOversize?: (file: VarFile) => any
+ onRemove?: (file: VarFile) => any
+ onInput?: (files: VarFile[]) => any
+}
+
+export class Uploader extends VarComponent {
+ $props: UploaderProps
+
+ getLoading(varFiles: VarFile[]): VarFile[]
+
+ getSuccess(varFiles: VarFile[]): VarFile[]
+
+ getError(varFiles: VarFile[]): VarFile[]
+
+ validate(): Promise
+
+ resetValidation(): void
+
+ reset(): void
+}
+
+export class _UploaderComponent extends Uploader {}