소스 검색

Transfer (#4337)

* add transfer

* Transfer: add English doc

* Transfer: add tests

* update locale files
杨奕 8 년 전
부모
커밋
ec3895fdba
43개의 변경된 파일1557개의 추가작업 그리고 6개의 파일을 삭제
  1. 2 2
      build/bin/new.js
  2. 2 1
      components.json
  3. 275 0
      examples/docs/en-US/transfer.md
  4. 281 0
      examples/docs/zh-CN/transfer.md
  5. 8 0
      examples/nav.config.json
  6. 13 0
      packages/theme-default/src/common/var.css
  7. 1 0
      packages/theme-default/src/index.css
  8. 168 0
      packages/theme-default/src/transfer.css
  9. 8 0
      packages/transfer/index.js
  10. 184 0
      packages/transfer/src/main.vue
  11. 229 0
      packages/transfer/src/transfer-panel.vue
  12. 4 1
      src/index.js
  13. 8 0
      src/locale/lang/bg.js
  14. 8 0
      src/locale/lang/ca.js
  15. 8 0
      src/locale/lang/cz.js
  16. 8 0
      src/locale/lang/da.js
  17. 8 0
      src/locale/lang/de.js
  18. 12 0
      src/locale/lang/el.js
  19. 8 0
      src/locale/lang/en.js
  20. 8 0
      src/locale/lang/es.js
  21. 8 0
      src/locale/lang/fa.js
  22. 12 0
      src/locale/lang/fi.js
  23. 9 1
      src/locale/lang/fr.js
  24. 8 0
      src/locale/lang/id.js
  25. 9 1
      src/locale/lang/it.js
  26. 8 0
      src/locale/lang/ja.js
  27. 8 0
      src/locale/lang/ko.js
  28. 8 0
      src/locale/lang/nb-NO.js
  29. 8 0
      src/locale/lang/nl.js
  30. 8 0
      src/locale/lang/pl.js
  31. 8 0
      src/locale/lang/pt-br.js
  32. 8 0
      src/locale/lang/pt.js
  33. 8 0
      src/locale/lang/ru-RU.js
  34. 12 0
      src/locale/lang/sk.js
  35. 12 0
      src/locale/lang/sv-SE.js
  36. 8 0
      src/locale/lang/th.js
  37. 8 0
      src/locale/lang/tk.js
  38. 8 0
      src/locale/lang/tr-TR.js
  39. 8 0
      src/locale/lang/ua.js
  40. 8 0
      src/locale/lang/vi.js
  41. 8 0
      src/locale/lang/zh-CN.js
  42. 8 0
      src/locale/lang/zh-TW.js
  43. 124 0
      test/unit/specs/transfer.spec.js

+ 2 - 2
build/bin/new.js

@@ -71,11 +71,11 @@ export default {
   },
   {
     filename: path.join('../../examples/docs/zh-CN', `${componentname}.md`),
-    content: `## ${chineseName}`
+    content: `## ${ComponentName} ${chineseName}`
   },
   {
     filename: path.join('../../examples/docs/en-US', `${componentname}.md`),
-    content: `## ${componentname}`
+    content: `## ${ComponentName}`
   },
   {
     filename: path.join('../../test/unit/specs', `${componentname}.spec.js`),

+ 2 - 1
components.json

@@ -61,5 +61,6 @@
   "collapse": "./packages/collapse/index.js",
   "collapse-item": "./packages/collapse-item/index.js",
   "cascader": "./packages/cascader/index.js",
-  "color-picker": "./packages/color-picker/index.js"
+  "color-picker": "./packages/color-picker/index.js",
+  "transfer": "./packages/transfer/index.js"
 }

+ 275 - 0
examples/docs/en-US/transfer.md

@@ -0,0 +1,275 @@
+<script>
+  export default {
+    data() {
+      const generateData = _ => {
+        const data = [];
+        for (let i = 1; i <= 15; i++) {
+          data.push({
+            key: i,
+            label: `Option ${ i }`,
+            disabled: i % 4 === 0
+          });
+        }
+        return data;
+      };
+      const generateData2 = _ => {
+        const data = [];
+        const states = ['California', 'Illinois', 'Maryland', 'Texas', 'Florida', 'Colorado', 'Connecticut '];
+        const initials = ['CA', 'IL', 'MD', 'TX', 'FL', 'CO', 'CT'];
+        states.forEach((city, index) => {
+          data.push({
+            label: city,
+            key: index,
+            initial: initials[index]
+          });
+        });
+        return data;
+      };
+      const generateData3 = _ => {
+        const data = [];
+        for (let i = 1; i <= 15; i++) {
+          data.push({
+            value: i,
+            desc: `Option ${ i }`,
+            disabled: i % 4 === 0
+          });
+        }
+        return data;
+      };
+      return {
+        data: generateData(),
+        data2: generateData2(),
+        data3: generateData3(),
+        value1: [1, 4],
+        value2: [],
+        value3: [1],
+        value4: [],
+        filterMethod(query, item) {
+          return item.initial.toLowerCase().indexOf(query.toLowerCase()) > -1;
+        },
+        renderFunc(h, option) {
+          return <span>{ option.key } - { option.label }</span>;
+        }
+      };
+    },
+
+    methods: {
+      handleChange(value, direction, movedKeys) {
+        console.log(value, direction, movedKeys);
+      }
+    }
+  };
+</script>
+
+## Transfer
+
+### Basic usage
+:::demo Data is passed to Transfer via the `data` attribute. The data needs to be an object array, and each object should have these attributes: `key` being the identification of the data item, `label` being the displayed text, and `disabled` indicating if the data item is disabled. Items inside the target list are in sync with the variable binding to `v-model`, and the value of that variable is an array of target item keys. So, if you don't want the target list be initially empty, you can initialize the `v-model` with an array.
+```html
+<template>
+  <el-transfer
+    v-model="value1"
+    :data="data">
+  </el-transfer>
+</template>
+
+<script>
+  export default {
+    data() {
+      const generateData = _ => {
+        const data = [];
+        for (let i = 1; i <= 15; i++) {
+          data.push({
+            key: i,
+            label: `Option ${ i }`,
+            disabled: i % 4 === 0
+          });
+        }
+        return data;
+      };
+      return {
+        data: generateData(),
+        value1: [1, 4]
+      };
+    }
+  };
+</script>
+```
+:::
+
+### Filterable
+
+You can search and filter data items.
+
+:::demo Set the `filterable` attribute to `true` to enable filter mode. By default, if the data item `label` contains the search keyword, it will be included in the search result. Also, you can implement you own filter method with the `filter-method` attribute. It takes a method and passes search keyword and each data item to it whenever the keyword changes. For a certain data item, if the method returns true, it will be included in the result list.
+```html
+<template>
+  <el-transfer
+    filterable
+    :filter-method="filterMethod"
+    filter-placeholder="State Abbreviations"
+    v-model="value2"
+    :data="data2">
+  </el-transfer>
+</template>
+
+<script>
+  export default {
+    data() {
+      const generateData2 = _ => {
+        const data = [];
+        const states = ['California', 'Illinois', 'Maryland', 'Texas', 'Florida', 'Colorado', 'Connecticut '];
+        const initials = ['CA', 'IL', 'MD', 'TX', 'FL', 'CO', 'CT'];
+        states.forEach((city, index) => {
+          data.push({
+            label: city,
+            key: index,
+            initial: initials[index]
+          });
+        });
+        return data;
+      };
+      return {
+        data2: generateData2(),
+        value2: [],
+        filterMethod(query, item) {
+          return item.initial.toLowerCase().indexOf(query.toLowerCase()) > -1;
+        }
+      };
+    }
+  };
+</script>
+```
+:::
+
+### Customizable
+
+You can customize list titles, button texts, render function for data items, checking status texts in list footer and list footer contents.
+
+:::demo Use `titles`, `button-texts`, `render-content` and `footer-format` to respectively customize list titles, button texts, render function for data items, checking status texts in list footer. For list footer contents, two named slots are provided: `left-footer` and `right-footer`. Plus, if you want some items initially checked, you can use `left-default-checked` and `right-default-checked`. Finally, this example demonstrate the `change` event. Note that this demo can't run in jsfiddle because it doesn't support JSX syntax. In a real project, `render-content` will work if relevant dependencies are correctly configured.
+```html
+<template>
+  <el-transfer
+    v-model="value3"
+    filterable
+    :left-default-checked="[2, 3]"
+    :right-default-checked="[1]"
+    :render-content="renderFunc"
+    :titles="['Source', 'Target']"
+    :button-texts="['To left', 'To right']"
+    :footer-format="{
+      noChecked: '${total}',
+      hasChecked: '${checked}/${total}'
+    }"
+    @change="handleChange"
+    :data="data">
+    <el-button class="transfer-footer" slot="left-footer" size="small">Operation</el-button>
+    <el-button class="transfer-footer" slot="right-footer" size="small">Operation</el-button>
+  </el-transfer>
+</template>
+
+<style>
+  .transfer-footer {
+    margin-left: 20px;
+    padding: 6px 5px;
+  }
+</style>
+
+<script>
+  export default {
+    data() {
+      const generateData = _ => {
+        const data = [];
+        for (let i = 1; i <= 15; i++) {
+          data.push({
+            key: i,
+            label: `Option ${ i }`,
+            disabled: i % 4 === 0
+          });
+        }
+        return data;
+      };
+      return {
+        data: generateData(),
+        value3: [1],
+        renderFunc(h, option) {
+          return <span>{ option.key } - { option.label }</span>;
+        }
+      };
+    },
+
+    methods: {
+      handleChange(value, direction, movedKeys) {
+        console.log(value, direction, movedKeys);
+      }
+    }
+  };
+</script>
+```
+:::
+
+### Prop aliases
+
+By default, Transfer looks for `key`, `label` and `disabled` in a data item. If your data items have different key names, you can use the `props` attribute to define aliases.
+:::demo The data items in this example do not have `key`s or `label`s, instead they have `value`s and `desc`s. So you need to set aliases for `key` and `label`.
+```html
+<template>
+  <el-transfer
+    v-model="value4"
+    :props="{
+      key: 'value',
+      label: 'desc'
+    }"
+    :data="data3">
+  </el-transfer>
+</template>
+
+<script>
+  export default {
+    data() {
+      const generateData3 = _ => {
+        const data = [];
+        for (let i = 1; i <= 15; i++) {
+          data.push({
+            value: i,
+            desc: `Option ${ i }`,
+            disabled: i % 4 === 0
+          });
+        }
+        return data;
+      };
+      return {
+        data3: generateData3(),
+        value4: []
+      };
+    }
+  };
+</script>
+```
+:::
+
+### Attributes
+| Attribute | Description | Type  | Accepted Values | Default |
+|---------- |-------- |---------- |-------------  |-------- |
+| data | data source | array[{ key, label, disabled }] | — | [ ] |
+| filterable | whether Transfer is filterable | boolean | — | false |
+| filter-placeholder | placeholder for the filter input | string | — | Enter keyword |
+| filter-method | custom filter method | function | — | — |
+| titles | custom list titles | array | — | ['List 1', 'List 2'] |
+| button-texts | custom button texts | array | — | [ ] |
+| render-content | custom render function for data items | function(h, option) | — | — |
+| footer-format | texts for checking status in list footer | object{noChecked, hasChecked} | — | { noChecked: '${total} items', hasChecked: '${checked}/${total} checked' } |
+| props | prop aliases for data source | object{key, label, disabled} | — | — |
+| left-default-checked | key array of initially checked data items of the left list | array | — | [ ] |
+| right-default-checked | key array of initially checked data items of the right list | array | — | [ ] |
+
+### Slot
+| Name | Description |
+|------|--------|
+| left-footer | content of left list footer |
+| right-footer | content of right list footer |
+
+### Events
+| Event Name | Description | Parameters |
+|---------- |-------- |---------- |
+| change | triggers when data items change in the right list | key array of current data items in the right list, transfer direction (left or right), moved item keys |

+ 281 - 0
examples/docs/zh-CN/transfer.md

@@ -0,0 +1,281 @@
+<style>
+  .demo-transfer {
+    .transfer-footer {
+      margin-left: 20px;
+      padding: 6px 5px;
+    }
+  }
+</style>
+
+<script>
+  export default {
+    data() {
+      const generateData = _ => {
+        const data = [];
+        for (let i = 1; i <= 15; i++) {
+          data.push({
+            key: i,
+            label: `备选项 ${ i }`,
+            disabled: i % 4 === 0
+          });
+        }
+        return data;
+      };
+      const generateData2 = _ => {
+        const data = [];
+        const cities = ['上海', '北京', '广州', '深圳', '南京', '西安', '成都'];
+        const pinyin = ['shanghai', 'beijing', 'guangzhou', 'shenzhen', 'nanjing', 'xian', 'chengdu'];
+        cities.forEach((city, index) => {
+          data.push({
+            label: city,
+            key: index,
+            pinyin: pinyin[index]
+          });
+        });
+        return data;
+      };
+      const generateData3 = _ => {
+        const data = [];
+        for (let i = 1; i <= 15; i++) {
+          data.push({
+            value: i,
+            desc: `备选项 ${ i }`,
+            disabled: i % 4 === 0
+          });
+        }
+        return data;
+      };
+      return {
+        data: generateData(),
+        data2: generateData2(),
+        data3: generateData3(),
+        value1: [1, 4],
+        value2: [],
+        value3: [1],
+        value4: [],
+        filterMethod(query, item) {
+          return item.pinyin.indexOf(query) > -1;
+        },
+        renderFunc(h, option) {
+          return <span>{ option.key } - { option.label }</span>;
+        }
+      };
+    },
+
+    methods: {
+      handleChange(value, direction, movedKeys) {
+        console.log(value, direction, movedKeys);
+      }
+    }
+  };
+</script>
+
+## Transfer 穿梭框
+
+### 基础用法
+:::demo Transfer 的数据通过 `data` 属性传入。数据需要是一个对象数组,每个对象有以下属性:`key` 为数据的唯一性标识,`label` 为显示文本,`disabled` 表示该项数据是否禁止转移。目标列表中的数据项会同步到绑定至 `v-model` 的变量,值为数据项的 `key` 所组成的数组。当然,如果希望在初始状态时目标列表不为空,可以像本例一样为 `v-model` 绑定的变量赋予一个初始值。
+```html
+<template>
+  <el-transfer v-model="value1" :data="data"></el-transfer>
+</template>
+
+<script>
+  export default {
+    data() {
+      const generateData = _ => {
+        const data = [];
+        for (let i = 1; i <= 15; i++) {
+          data.push({
+            key: i,
+            label: `备选项 ${ i }`,
+            disabled: i % 4 === 0
+          });
+        }
+        return data;
+      };
+      return {
+        data: generateData(),
+        value1: [1, 4]
+      };
+    }
+  };
+</script>
+```
+:::
+
+### 可搜索
+
+在数据很多的情况下,可以对数据进行搜索和过滤。
+
+:::demo 设置 `filterable` 为 `true` 即可开启搜索模式。默认情况下,若数据项的 `label` 属性包含搜索关键字,则会在搜索结果中显示。你也可以使用 `filter-method` 定义自己的搜索逻辑。`filter-method` 接收一个方法,当搜索关键字变化时,会将当前的关键字和每个数据项传给该方法。若方法返回 `true`,则会在搜索结果中显示对应的数据项。
+```html
+<template>
+  <el-transfer
+    filterable
+    :filter-method="filterMethod"
+    filter-placeholder="请输入城市拼音"
+    v-model="value2"
+    :data="data2">
+  </el-transfer>
+</template>
+
+<script>
+  export default {
+    data() {
+      const generateData2 = _ => {
+        const data = [];
+        const cities = ['上海', '北京', '广州', '深圳', '南京', '西安', '成都'];
+        const pinyin = ['shanghai', 'beijing', 'guangzhou', 'shenzhen', 'nanjing', 'xian', 'chengdu'];
+        cities.forEach((city, index) => {
+          data.push({
+            label: city,
+            key: index,
+            pinyin: pinyin[index]
+          });
+        });
+        return data;
+      };
+      return {
+        data2: generateData2(),
+        value2: [],
+        filterMethod(query, item) {
+          return item.pinyin.indexOf(query) > -1;
+        }
+      };
+    }
+  };
+</script>
+```
+:::
+
+### 可自定义
+
+可以对列表标题文案、按钮文案、数据项的渲染函数、列表底部的勾选状态文案、列表底部的内容区等进行自定义。
+
+:::demo 可以使用 `titles`、`button-texts`、`render-content` 和 `footer-format` 属性分别对列表标题文案、按钮文案、数据项的渲染函数和列表底部的勾选状态文案进行自定义。对于列表底部的内容区,提供了两个具名 slot:`left-footer` 和 `right-footer`。此外,如果希望某些数据项在初始化时就被勾选,可以使用 `left-default-checked` 和 `right-default-checked` 属性。最后,本例还展示了 `change` 事件的用法。注意:由于 jsfiddle 不支持 JSX 语法,所以本例在 jsfiddle 中无法运行。但是在实际的项目中,只要正确地配置了相关依赖,就可以正常运行。
+```html
+<template>
+  <el-transfer
+    v-model="value3"
+    filterable
+    :left-default-checked="[2, 3]"
+    :right-default-checked="[1]"
+    :render-content="renderFunc"
+    :titles="['Source', 'Target']"
+    :button-texts="['到左边', '到右边']"
+    :footer-format="{
+      noChecked: '${total}',
+      hasChecked: '${checked}/${total}'
+    }"
+    @change="handleChange"
+    :data="data">
+    <el-button class="transfer-footer" slot="left-footer" size="small">操作</el-button>
+    <el-button class="transfer-footer" slot="right-footer" size="small">操作</el-button>
+  </el-transfer>
+</template>
+
+<style>
+  .transfer-footer {
+    margin-left: 20px;
+    padding: 6px 5px;
+  }
+</style>
+
+<script>
+  export default {
+    data() {
+      const generateData = _ => {
+        const data = [];
+        for (let i = 1; i <= 15; i++) {
+          data.push({
+            key: i,
+            label: `备选项 ${ i }`,
+            disabled: i % 4 === 0
+          });
+        }
+        return data;
+      };
+      return {
+        data: generateData(),
+        value3: [1],
+        renderFunc(h, option) {
+          return <span>{ option.key } - { option.label }</span>;
+        }
+      };
+    },
+
+    methods: {
+      handleChange(value, direction, movedKeys) {
+        console.log(value, direction, movedKeys);
+      }
+    }
+  };
+</script>
+```
+:::
+
+### 数据项属性别名
+
+默认情况下,Transfer 仅能识别数据项中的 `key`、`label` 和 `disabled` 字段。如果你的数据的字段名不同,可以使用 `props` 属性为它们设置别名。
+:::demo 本例中的数据源没有 `key` 和 `label` 字段,在功能上与它们相同的字段名为 `value` 和 `desc`。因此可以使用`props` 属性为 `key` 和 `label` 设置别名。
+```html
+<template>
+  <el-transfer
+    v-model="value4"
+    :props="{
+      key: 'value',
+      label: 'desc'
+    }"
+    :data="data3">
+  </el-transfer>
+</template>
+
+<script>
+  export default {
+    data() {
+      const generateData3 = _ => {
+        const data = [];
+        for (let i = 1; i <= 15; i++) {
+          data.push({
+            value: i,
+            desc: `备选项 ${ i }`,
+            disabled: i % 4 === 0
+          });
+        }
+        return data;
+      };
+      return {
+        data3: generateData3(),
+        value4: []
+      };
+    }
+  };
+</script>
+```
+:::
+
+### Attributes
+| 参数      | 说明    | 类型      | 可选值       | 默认值   |
+|---------- |-------- |---------- |-------------  |-------- |
+| data | Transfer 的数据源 | array[{ key, label, disabled }] | — | [ ] |
+| filterable | 是否可搜索 | boolean | — | false |
+| filter-placeholder | 搜索框占位符 | string | — | 请输入搜索内容 |
+| filter-method | 自定义搜索方法 | function | — | — |
+| titles | 自定义列表标题 | array | — | ['列表 1', '列表 2'] |
+| button-texts | 自定义按钮文案 | array | — | [ ] |
+| render-content | 自定义数据项渲染函数 | function(h, option) | — | — |
+| footer-format | 列表底部勾选状态文案 | object{noChecked, hasChecked} | — | { noChecked: '共 ${total} 项', hasChecked: '已选 ${checked}/${total} 项' } |
+| props | 数据源的字段别名 | object{key, label, disabled} | — | — |
+| left-default-checked | 初始状态下左侧列表的已勾选项的 key 数组 | array | — | [ ] |
+| right-default-checked | 初始状态下右侧列表的已勾选项的 key 数组 | array | — | [ ] |
+
+### Slot
+| name | 说明 |
+|------|--------|
+| left-footer | 左侧列表底部的内容 |
+| right-footer | 右侧列表底部的内容 |
+
+### Events
+| 事件名称      | 说明    | 回调参数      |
+|---------- |-------- |---------- |
+| change | 右侧列表元素变化时触发 | 当前值、数据移动的方向('left' / 'right')、发生移动的数据 key 数组 |

+ 8 - 0
examples/nav.config.json

@@ -116,6 +116,10 @@
               "path": "/color-picker",
               "title": "ColorPicker 颜色选择器"
             },
+            {
+              "path": "/transfer",
+              "title": "Transfer 穿梭框"
+            },
             {
               "path": "/form",
               "title": "Form 表单"
@@ -350,6 +354,10 @@
               "path": "/color-picker",
               "title": "ColorPicker"
             },
+            {
+              "path": "/transfer",
+              "title": "Transfer"
+            },
             {
               "path": "/form",
               "title": "Form"

+ 13 - 0
packages/theme-default/src/common/var.css

@@ -589,4 +589,17 @@
   --collapse-content-fill: var(--color-dark-white);
   --collapse-content-size: 13px;
   --collapse-content-color: var(--color-base-black);
+
+  /* Transfer
+  --------------------------*/
+  --transfer-border-color: var(--color-base-gray);
+  --transfer-box-shadow: var(--box-shadow-base);
+  --transfer-panel-width: 200px;
+  --transfer-panel-header-height: 36px;
+  --transfer-panel-header-background: var(--color-dark-white);
+  --transfer-panel-footer-height: 36px;
+  --transfer-panel-body-height: 246px;
+  --transfer-item-height: 32px;
+  --transfer-item-hover-background: var(--color-light-gray);
+  --transfer-filter-height: 22px;
 }

+ 1 - 0
packages/theme-default/src/index.css

@@ -59,3 +59,4 @@
 @import "./collapse-item.css";
 @import "./cascader.css";
 @import "./color-picker.css";
+@import "./transfer.css";

+ 168 - 0
packages/theme-default/src/transfer.css

@@ -0,0 +1,168 @@
+@charset "UTF-8";
+@import "./common/var.css";
+@import "./input.css";
+@import "./button.css";
+@import "./checkbox.css";
+@import "./checkbox-group.css";
+
+@component-namespace el {
+  @b transfer {
+    font-size: var(--font-size-base);
+
+    @e buttons {
+      display: inline-block;
+      vertical-align: middle;
+      padding: 0 10px;
+
+      .el-button {
+        display: block;
+        margin: 0 auto;
+        padding: 8px 12px;
+
+        &:first-child {
+          margin-bottom: 6px;
+        }
+      }
+
+      .el-button [class*="el-icon-"] + span {
+        margin-left: 0;
+      }
+    }
+  }
+
+  @b transfer-panel {
+    border: 1px solid var(--transfer-border-color);
+    background: var(--color-white);
+    box-shadow: var(--transfer-box-shadow);
+    display: inline-block;
+    vertical-align: middle;
+    width: var(--transfer-panel-width);
+    box-sizing: border-box;
+    position: relative;
+
+    @e body {
+      padding-bottom: var(--transfer-panel-footer-height);
+      height: var(--transfer-panel-body-height);
+    }
+
+    @e list {
+      margin: 0;
+      padding: 6px 0;
+      list-style: none;
+      height: var(--transfer-panel-body-height);
+      overflow: auto;
+      box-sizing: border-box;
+
+      @when filterable {
+        height: calc(var(--transfer-panel-body-height) - var(--transfer-filter-height) - 10px);
+      }
+    }
+
+    @e item {
+      height: var(--transfer-item-height);
+      line-height: var(--transfer-item-height);
+      padding-left: 20px;
+      display: block;
+
+      & + .el-transfer-panel__item {
+        margin-left: 0;
+      }
+
+      &.el-checkbox {
+        color: var(--color-extra-light-black);
+      }
+
+      &:hover {
+        background: var(--transfer-item-hover-background);
+      }
+
+      .el-checkbox__label {
+        width: 100%;
+        @utils-ellipsis;
+        display: block;
+        box-sizing: border-box;
+        padding-left: 28px;
+      }
+
+      .el-checkbox__input {
+        position: absolute;
+        top: 9px;
+      }
+    }
+
+    @e filter {
+      margin-top: 10px;
+      text-align: center;
+      padding: 0 10px;
+      width: 100%;
+      box-sizing: border-box;
+
+      .el-input__inner {
+        height: var(--transfer-filter-height);
+        width: 100%;
+        display: inline-block;
+        box-sizing: border-box;
+      }
+
+      .el-input__icon {
+        right: 10px;
+      }
+
+      .el-icon-circle-close {
+        cursor: pointer;
+      }
+    }
+
+    .el-transfer-panel__header {
+      height: var(--transfer-panel-header-height);
+      line-height: var(--transfer-panel-header-height);
+      background: var(--transfer-panel-header-background);
+      margin: 0;
+      padding-left: 20px;
+      border-bottom: 1px solid var(--transfer-border-color);
+      box-sizing: border-box;
+      color: var(--color-base-black);
+    }
+
+    .el-transfer-panel__footer {
+      height: var(--transfer-panel-footer-height);
+      background: var(--color-white);
+      margin: 0;
+      padding: 0;
+      border-top: 1px solid var(--transfer-border-color);
+      position: absolute;
+      bottom: 0;
+      left: 0;
+      width: 100%;
+      z-index: var(--index-normal);
+      @utils-vertical-center;
+
+      .el-checkbox {
+        padding-left: 20px;
+        color: var(--color-base-silver);
+      }
+    }
+
+    .el-transfer-panel__empty {
+      margin: 0;
+      height: var(--transfer-item-height);
+      line-height: var(--transfer-item-height);
+      padding: 6px 20px 0;
+      color: var(--color-base-silver);
+    }
+
+    .el-checkbox__label {
+      padding-left: 14px;
+    }
+
+    .el-checkbox__inner {
+      size: 14px;
+      border-radius: 3px;
+      &::after {
+        height: 6px;
+        width: 3px;
+        left: 4px;
+      }
+    }
+  }
+}

+ 8 - 0
packages/transfer/index.js

@@ -0,0 +1,8 @@
+import Transfer from './src/main';
+
+/* istanbul ignore next */
+Transfer.install = function(Vue) {
+  Vue.component(Transfer.name, Transfer);
+};
+
+export default Transfer;

+ 184 - 0
packages/transfer/src/main.vue

@@ -0,0 +1,184 @@
+<template>
+  <div class="el-transfer">
+    <transfer-panel
+      :filterable="filterable"
+      :filter-method="filterMethod"
+      :data="sourceData"
+      :render-content="renderContent"
+      :title="titles[0] || t('el.transfer.titles.0')"
+      :format="footerFormat"
+      :default-checked="leftDefaultChecked"
+      :placeholder="filterPlaceholder || t('el.transfer.filterPlaceholder')"
+      :props="props"
+      @checked-change="onSourceCheckedChange">
+      <slot name="left-footer"></slot>
+    </transfer-panel>
+    <div class="el-transfer__buttons">
+      <el-button
+        type="primary"
+        size="small"
+        @click.native="addToLeft"
+        :disabled="rightChecked.length === 0">
+        <i class="el-icon-arrow-left"></i>
+        <span v-if="buttonTexts[0] !== undefined">{{ buttonTexts[0] }}</span>
+      </el-button>
+      <el-button
+        type="primary"
+        size="small"
+        @click.native="addToRight"
+        :disabled="leftChecked.length === 0">
+        <span v-if="buttonTexts[1] !== undefined">{{ buttonTexts[1] }}</span>
+        <i class="el-icon-arrow-right"></i>
+      </el-button>
+    </div>
+    <transfer-panel
+      :filterable="filterable"
+      :filter-method="filterMethod"
+      :data="targetData"
+      :render-content="renderContent"
+      :title="titles[1] || t('el.transfer.titles.1')"
+      :format="footerFormat"
+      :default-checked="rightDefaultChecked"
+      :placeholder="filterPlaceholder || t('el.transfer.filterPlaceholder')"
+      :props="props"
+      @checked-change="onTargetCheckedChange">
+      <slot name="right-footer"></slot>
+    </transfer-panel>
+  </div>
+</template>
+
+<script>
+  import ElButton from 'element-ui/packages/button';
+  import Emitter from 'element-ui/src/mixins/emitter';
+  import Locale from 'element-ui/src/mixins/locale';
+  import TransferPanel from './transfer-panel.vue';
+
+  export default {
+    name: 'ElTransfer',
+
+    mixins: [Emitter, Locale],
+
+    components: {
+      TransferPanel,
+      ElButton
+    },
+
+    props: {
+      data: {
+        type: Array,
+        default() {
+          return [];
+        }
+      },
+      titles: {
+        type: Array,
+        default() {
+          return [];
+        }
+      },
+      buttonTexts: {
+        type: Array,
+        default() {
+          return [];
+        }
+      },
+      filterPlaceholder: {
+        type: String,
+        default: ''
+      },
+      filterMethod: Function,
+      leftDefaultChecked: {
+        type: Array,
+        default() {
+          return [];
+        }
+      },
+      rightDefaultChecked: {
+        type: Array,
+        default() {
+          return [];
+        }
+      },
+      renderContent: Function,
+      value: {
+        type: Array,
+        default() {
+          return [];
+        }
+      },
+      footerFormat: {
+        type: Object,
+        default() {
+          return {};
+        }
+      },
+      filterable: Boolean,
+      props: {
+        type: Object,
+        default() {
+          return {
+            label: 'label',
+            key: 'key',
+            disabled: 'disabled'
+          };
+        }
+      }
+    },
+
+    data() {
+      return {
+        leftChecked: [],
+        rightChecked: []
+      };
+    },
+
+    computed: {
+      sourceData() {
+        return this.data.filter(item => this.value.indexOf(item[this.props.key]) === -1);
+      },
+
+      targetData() {
+        return this.data.filter(item => this.value.indexOf(item[this.props.key]) > -1);
+      }
+    },
+
+    watch: {
+      value(val) {
+        this.dispatch('ElFormItem', 'el.form.change', val);
+      }
+    },
+
+    methods: {
+      onSourceCheckedChange(val) {
+        this.leftChecked = val;
+      },
+
+      onTargetCheckedChange(val) {
+        this.rightChecked = val;
+      },
+
+      addToLeft() {
+        let currentValue = this.value.slice();
+        this.rightChecked.forEach(item => {
+          const index = currentValue.indexOf(item);
+          if (index > -1) {
+            currentValue.splice(index, 1);
+          }
+        });
+        this.$emit('input', currentValue);
+        this.$emit('change', currentValue, 'left', this.rightChecked);
+      },
+
+      addToRight() {
+        let currentValue = this.value.slice();
+        this.leftChecked.forEach(item => {
+          if (this.value.indexOf(item) === -1) {
+            currentValue = currentValue.concat(item);
+          }
+        });
+        this.$emit('input', currentValue);
+        this.$emit('change', currentValue, 'right', this.leftChecked);
+      }
+    }
+  };
+</script>

+ 229 - 0
packages/transfer/src/transfer-panel.vue

@@ -0,0 +1,229 @@
+<template>
+  <div class="el-transfer-panel">
+    <p class="el-transfer-panel__header">{{ title }}</p>
+    
+    <div class="el-transfer-panel__body">
+      <el-input
+        class="el-transfer-panel__filter"
+        v-model="query"
+        size="small"
+        :placeholder="placeholder"
+        :icon="inputIcon"
+        @mouseenter.native="inputHover = true"
+        @mouseleave.native="inputHover = false"
+        @click="clearQuery"
+        v-if="filterable"></el-input>
+      <el-checkbox-group
+        v-model="checked"
+        v-show="!hasNoMatch && data.length > 0"
+        :class="{ 'is-filterable': filterable }"
+        class="el-transfer-panel__list">
+        <el-checkbox
+          class="el-transfer-panel__item"
+          :label="item[keyProp]"
+          :disabled="item[disabledProp]"
+          v-for="item in filteredData">
+          <option-content :option="item"></option-content>
+        </el-checkbox>
+      </el-checkbox-group>
+      <p
+        class="el-transfer-panel__empty"
+        v-show="hasNoMatch">{{ t('el.transfer.noMatch') }}</p>
+      <p
+        class="el-transfer-panel__empty"
+        v-show="data.length === 0 && !hasNoMatch">{{ t('el.transfer.noData') }}</p>
+    </div>
+    
+    <p class="el-transfer-panel__footer">
+      <el-checkbox
+        v-model="allChecked"
+        @change="handleAllCheckedChange"
+        :indeterminate="isIndeterminate">{{ checkedSummary }}</el-checkbox>
+      <slot></slot>
+    </p>
+  </div>
+</template>
+
+<script>
+  import ElCheckboxGroup from 'element-ui/packages/checkbox-group';
+  import ElCheckbox from 'element-ui/packages/checkbox';
+  import ElInput from 'element-ui/packages/input';
+  import Locale from 'element-ui/src/mixins/locale';
+
+  export default {
+    mixins: [Locale],
+
+    name: 'ElTransferPanel',
+
+    componentName: 'ElTransferPanel',
+
+    components: {
+      ElCheckboxGroup,
+      ElCheckbox,
+      ElInput,
+      OptionContent: {
+        props: {
+          option: Object
+        },
+        render(h) {
+          const getParent = vm => {
+            if (vm.$options.componentName === 'ElTransferPanel') {
+              return vm;
+            } else if (vm.$parent) {
+              return getParent(vm.$parent);
+            } else {
+              return vm;
+            }
+          };
+          const parent = getParent(this);
+          return parent.renderContent
+            ? parent.renderContent(h, this.option)
+            : <span>{ this.option[parent.labelProp] || this.option[parent.keyProp] }</span>;
+        }
+      }
+    },
+
+    props: {
+      data: {
+        type: Array,
+        default() {
+          return [];
+        }
+      },
+      renderContent: Function,
+      placeholder: String,
+      title: String,
+      filterable: Boolean,
+      format: Object,
+      filterMethod: Function,
+      defaultChecked: Array,
+      props: Object
+    },
+
+    data() {
+      return {
+        checked: [],
+        allChecked: false,
+        query: '',
+        inputHover: false
+      };
+    },
+
+    watch: {
+      checked(val) {
+        this.updateAllChecked();
+        this.$emit('checked-change', val);
+      },
+
+      data() {
+        const checked = [];
+        const filteredDataKeys = this.filteredData.map(item => item[this.keyProp]);
+        this.checked.forEach(item => {
+          if (filteredDataKeys.indexOf(item) > -1) {
+            checked.push(item);
+          }
+        });
+        this.checked = checked;
+      },
+
+      checkableData() {
+        this.updateAllChecked();
+      },
+
+      defaultChecked: {
+        immediate: true,
+        handler(val, oldVal) {
+          if (oldVal && val.length === oldVal.length &&
+            val.every(item => oldVal.indexOf(item) > -1)) return;
+          const checked = [];
+          const checkableDataKeys = this.checkableData.map(item => item[this.keyProp]);
+          val.forEach(item => {
+            if (checkableDataKeys.indexOf(item) > -1) {
+              checked.push(item);
+            }
+          });
+          this.checked = checked;
+        }
+      }
+    },
+
+    computed: {
+      filteredData() {
+        return this.data.filter(item => {
+          if (typeof this.filterMethod === 'function') {
+            return this.filterMethod(this.query, item);
+          } else {
+            const label = item[this.labelProp] || item[this.keyProp].toString();
+            return label.toLowerCase().indexOf(this.query.toLowerCase()) > -1;
+          }
+        });
+      },
+
+      checkableData() {
+        return this.filteredData.filter(item => !item[this.disabledProp]);
+      },
+
+      checkedSummary() {
+        const checkedLength = this.checked.length;
+        const dataLength = this.data.length;
+        const { noChecked, hasChecked } = this.format;
+        if (noChecked && hasChecked) {
+          return checkedLength > 0
+            ? hasChecked.replace(/\${checked}/g, checkedLength).replace(/\${total}/g, dataLength)
+            : noChecked.replace(/\${total}/g, dataLength);
+        } else {
+          return checkedLength > 0
+            ? this.t('el.transfer.hasCheckedFormat', { total: dataLength, checked: checkedLength })
+            : this.t('el.transfer.noCheckedFormat', { total: dataLength });
+        }
+      },
+
+      isIndeterminate() {
+        const checkedLength = this.checked.length;
+        return checkedLength > 0 && checkedLength < this.checkableData.length;
+      },
+
+      hasNoMatch() {
+        return this.query.length > 0 && this.filteredData.length === 0;
+      },
+
+      inputIcon() {
+        return this.query.length > 0 && this.inputHover
+          ? 'circle-close'
+          : 'search';
+      },
+
+      labelProp() {
+        return this.props.label || 'label';
+      },
+
+      keyProp() {
+        return this.props.key || 'key';
+      },
+
+      disabledProp() {
+        return this.props.disabled || 'disabled';
+      }
+    },
+
+    methods: {
+      updateAllChecked() {
+        const checkableDataKeys = this.checkableData.map(item => item[this.keyProp]);
+        this.allChecked = checkableDataKeys.length > 0 &&
+          checkableDataKeys.every(item => this.checked.indexOf(item) > -1);
+      },
+
+      handleAllCheckedChange(value) {
+        this.checked = value.target.checked
+          ? this.checkableData.map(item => item[this.keyProp])
+          : [];
+      },
+
+      clearQuery() {
+        if (this.inputIcon === 'circle-close') {
+          this.query = '';
+        }
+      }
+    }
+  };
+</script>

+ 4 - 1
src/index.js

@@ -63,6 +63,7 @@ import Collapse from '../packages/collapse/index.js';
 import CollapseItem from '../packages/collapse-item/index.js';
 import Cascader from '../packages/cascader/index.js';
 import ColorPicker from '../packages/color-picker/index.js';
+import Transfer from '../packages/transfer/index.js';
 import locale from 'element-ui/src/locale';
 import CollapseTransition from 'element-ui/src/transitions/collapse-transition';
 
@@ -125,6 +126,7 @@ const components = [
   Collapse,
   CollapseItem,
   Cascader,
+  Transfer,
   ColorPicker,
   CollapseTransition
 ];
@@ -223,5 +225,6 @@ module.exports = {
   Collapse,
   CollapseItem,
   Cascader,
-  ColorPicker
+  ColorPicker,
+  Transfer
 };

+ 8 - 0
src/locale/lang/bg.js

@@ -90,6 +90,14 @@ export default {
     },
     tree: {
       emptyText: 'Няма данни'
+    },
+    transfer: {
+      noMatch: 'Няма намерени',
+      noData: 'Няма данни',
+      titles: ['List 1', 'List 2'], // to be translated
+      filterPlaceholder: 'Enter keyword', // to be translated
+      noCheckedFormat: '{total} items', // to be translated
+      hasCheckedFormat: '{checked}/{total} checked' // to be translated
     }
   }
 };

+ 8 - 0
src/locale/lang/ca.js

@@ -89,6 +89,14 @@ export default {
     },
     tree: {
       emptyText: 'Sense Dades'
+    },
+    transfer: {
+      noMatch: 'No hi ha dades que coincideixin',
+      noData: 'Sense Dades',
+      titles: ['List 1', 'List 2'], // to be translated
+      filterPlaceholder: 'Enter keyword', // to be translated
+      noCheckedFormat: '{total} items', // to be translated
+      hasCheckedFormat: '{checked}/{total} checked' // to be translated
     }
   }
 };

+ 8 - 0
src/locale/lang/cz.js

@@ -92,6 +92,14 @@ export default {
     },
     tree: {
       emptyText: 'Žádná data'
+    },
+    transfer: {
+      noMatch: 'Žádná shoda',
+      noData: 'Žádná data',
+      titles: ['List 1', 'List 2'], // to be translated
+      filterPlaceholder: 'Enter keyword', // to be translated
+      noCheckedFormat: '{total} items', // to be translated
+      hasCheckedFormat: '{checked}/{total} checked' // to be translated
     }
   }
 };

+ 8 - 0
src/locale/lang/da.js

@@ -89,6 +89,14 @@ export default {
     },
     tree: {
       emptyText: 'Ingen data'
+    },
+    transfer: {
+      noMatch: 'Ingen matchende data',
+      noData: 'Ingen data',
+      titles: ['List 1', 'List 2'], // to be translated
+      filterPlaceholder: 'Enter keyword', // to be translated
+      noCheckedFormat: '{total} items', // to be translated
+      hasCheckedFormat: '{checked}/{total} checked' // to be translated
     }
   }
 };

+ 8 - 0
src/locale/lang/de.js

@@ -91,6 +91,14 @@ export default {
     },
     tree: {
       emptyText: 'Keine Daten'
+    },
+    transfer: {
+      noMatch: 'Nichts gefunden.',
+      noData: 'Keine Datei',
+      titles: ['List 1', 'List 2'], // to be translated
+      filterPlaceholder: 'Enter keyword', // to be translated
+      noCheckedFormat: '{total} items', // to be translated
+      hasCheckedFormat: '{checked}/{total} checked' // to be translated
     }
   }
 };

+ 12 - 0
src/locale/lang/el.js

@@ -60,6 +60,10 @@ export default {
       noData: 'Χωρίς δεδομένα',
       placeholder: 'Επιλογή'
     },
+    cascader: {
+      noMatch: 'Δεν βρέθηκαν αποτελέσματα',
+      placeholder: 'Επιλογή'
+    },
     pagination: {
       goto: 'Μετάβαση σε',
       pagesize: '/σελίδα',
@@ -86,6 +90,14 @@ export default {
     },
     tree: {
       emptyText: 'Χωρίς Δεδομένα'
+    },
+    transfer: {
+      noMatch: 'Δεν βρέθηκαν αποτελέσματα',
+      noData: 'Χωρίς δεδομένα',
+      titles: ['List 1', 'List 2'], // to be translated
+      filterPlaceholder: 'Enter keyword', // to be translated
+      noCheckedFormat: '{total} items', // to be translated
+      hasCheckedFormat: '{checked}/{total} checked' // to be translated
     }
   }
 };

+ 8 - 0
src/locale/lang/en.js

@@ -90,6 +90,14 @@ export default {
     },
     tree: {
       emptyText: 'No Data'
+    },
+    transfer: {
+      noMatch: 'No matching data',
+      noData: 'No data',
+      titles: ['List 1', 'List 2'], // to be translated
+      filterPlaceholder: 'Enter keyword', // to be translated
+      noCheckedFormat: '{total} items', // to be translated
+      hasCheckedFormat: '{checked}/{total} checked' // to be translated
     }
   }
 };

+ 8 - 0
src/locale/lang/es.js

@@ -89,6 +89,14 @@ export default {
     },
     tree: {
       emptyText: 'Sin Datos'
+    },
+    transfer: {
+      noMatch: 'No hay datos que coincidan',
+      noData: 'Sin datos',
+      titles: ['List 1', 'List 2'], // to be translated
+      filterPlaceholder: 'Enter keyword', // to be translated
+      noCheckedFormat: '{total} items', // to be translated
+      hasCheckedFormat: '{checked}/{total} checked' // to be translated
     }
   }
 };

+ 8 - 0
src/locale/lang/fa.js

@@ -90,6 +90,14 @@ export default {
     },
     tree: {
       emptyText: 'اطلاعاتی وجود ندارد'
+    },
+    transfer: {
+      noMatch: 'هیچ داده‌ای پیدا نشد',
+      noData: 'اطلاعاتی وجود ندارد',
+      titles: ['List 1', 'List 2'], // to be translated
+      filterPlaceholder: 'Enter keyword', // to be translated
+      noCheckedFormat: '{total} items', // to be translated
+      hasCheckedFormat: '{checked}/{total} checked' // to be translated
     }
   }
 };

+ 12 - 0
src/locale/lang/fi.js

@@ -60,6 +60,10 @@ export default {
       noData: 'Ei tietoja',
       placeholder: 'Valitse'
     },
+    cascader: {
+      noMatch: 'Ei vastaavia tietoja',
+      placeholder: 'Valitse'
+    },
     pagination: {
       goto: 'Mene',
       pagesize: '/sivu',
@@ -86,6 +90,14 @@ export default {
     },
     tree: {
       emptyText: 'Ei tietoja'
+    },
+    transfer: {
+      noMatch: 'Ei vastaavia tietoja',
+      noData: 'Ei tietoja',
+      titles: ['List 1', 'List 2'], // to be translated
+      filterPlaceholder: 'Enter keyword', // to be translated
+      noCheckedFormat: '{total} items', // to be translated
+      hasCheckedFormat: '{checked}/{total} checked' // to be translated
     }
   }
 };

+ 9 - 1
src/locale/lang/fr.js

@@ -57,7 +57,7 @@ export default {
     select: {
       loading: 'Chargement',
       noMatch: 'Aucune correspondance',
-      noData: 'Aucun résultat',
+      noData: 'Aucune donnée',
       placeholder: 'Choisir'
     },
     cascader: {
@@ -89,6 +89,14 @@ export default {
     },
     tree: {
       emptyText: 'Aucune donnée'
+    },
+    transfer: {
+      noMatch: 'Aucune correspondance',
+      noData: 'Aucune donnée',
+      titles: ['List 1', 'List 2'], // to be translated
+      filterPlaceholder: 'Enter keyword', // to be translated
+      noCheckedFormat: '{total} items', // to be translated
+      hasCheckedFormat: '{checked}/{total} checked' // to be translated
     }
   }
 };

+ 8 - 0
src/locale/lang/id.js

@@ -90,6 +90,14 @@ export default {
     },
     tree: {
       emptyText: 'Tidak Ada Data'
+    },
+    transfer: {
+      noMatch: 'Tidak ada data yang cocok',
+      noData: 'Tidak ada data',
+      titles: ['List 1', 'List 2'], // to be translated
+      filterPlaceholder: 'Enter keyword', // to be translated
+      noCheckedFormat: '{total} items', // to be translated
+      hasCheckedFormat: '{checked}/{total} checked' // to be translated
     }
   }
 };

+ 9 - 1
src/locale/lang/it.js

@@ -57,7 +57,7 @@ export default {
     select: {
       loading: 'Caricamento',
       noMatch: 'Nessuna corrispondenza',
-      noData: 'Nessun risultato',
+      noData: 'Nessun dato',
       placeholder: 'Seleziona'
     },
     cascader: {
@@ -89,6 +89,14 @@ export default {
     },
     tree: {
       emptyText: 'Nessun dato'
+    },
+    transfer: {
+      noMatch: 'Nessuna corrispondenza',
+      noData: 'Nessun dato',
+      titles: ['List 1', 'List 2'], // to be translated
+      filterPlaceholder: 'Enter keyword', // to be translated
+      noCheckedFormat: '{total} items', // to be translated
+      hasCheckedFormat: '{checked}/{total} checked' // to be translated
     }
   }
 };

+ 8 - 0
src/locale/lang/ja.js

@@ -90,6 +90,14 @@ export default {
     },
     tree: {
       emptyText: 'データなし'
+    },
+    transfer: {
+      noMatch: 'データなし',
+      noData: 'データなし',
+      titles: ['List 1', 'List 2'], // to be translated
+      filterPlaceholder: 'Enter keyword', // to be translated
+      noCheckedFormat: '{total} items', // to be translated
+      hasCheckedFormat: '{checked}/{total} checked' // to be translated
     }
   }
 };

+ 8 - 0
src/locale/lang/ko.js

@@ -90,6 +90,14 @@ export default {
     },
     tree: {
       emptyText: '데이터 없음'
+    },
+    transfer: {
+      noMatch: '맞는 데이터가 없습니다',
+      noData: '데이터 없음',
+      titles: ['List 1', 'List 2'], // to be translated
+      filterPlaceholder: 'Enter keyword', // to be translated
+      noCheckedFormat: '{total} items', // to be translated
+      hasCheckedFormat: '{checked}/{total} checked' // to be translated
     }
   }
 };

+ 8 - 0
src/locale/lang/nb-NO.js

@@ -89,6 +89,14 @@ export default {
     },
     tree: {
       emptyText: 'Ingen Data'
+    },
+    transfer: {
+      noMatch: 'Ingen samsvarende data',
+      noData: 'Ingen data',
+      titles: ['List 1', 'List 2'], // to be translated
+      filterPlaceholder: 'Enter keyword', // to be translated
+      noCheckedFormat: '{total} items', // to be translated
+      hasCheckedFormat: '{checked}/{total} checked' // to be translated
     }
   }
 };

+ 8 - 0
src/locale/lang/nl.js

@@ -90,6 +90,14 @@ export default {
     },
     tree: {
       emptyText: 'Geen data'
+    },
+    transfer: {
+      noMatch: 'Geen overeenkomende resultaten',
+      noData: 'Geen data',
+      titles: ['List 1', 'List 2'], // to be translated
+      filterPlaceholder: 'Enter keyword', // to be translated
+      noCheckedFormat: '{total} items', // to be translated
+      hasCheckedFormat: '{checked}/{total} checked' // to be translated
     }
   }
 };

+ 8 - 0
src/locale/lang/pl.js

@@ -90,6 +90,14 @@ export default {
     },
     tree: {
       emptyText: 'Brak danych'
+    },
+    transfer: {
+      noMatch: 'Brak dopasowań',
+      noData: 'Brak danych',
+      titles: ['List 1', 'List 2'], // to be translated
+      filterPlaceholder: 'Enter keyword', // to be translated
+      noCheckedFormat: '{total} items', // to be translated
+      hasCheckedFormat: '{checked}/{total} checked' // to be translated
     }
   }
 };

+ 8 - 0
src/locale/lang/pt-br.js

@@ -90,6 +90,14 @@ export default {
     },
     tree: {
       emptyText: 'Sem dados'
+    },
+    transfer: {
+      noMatch: 'Sem resultados',
+      noData: 'Sem dados',
+      titles: ['List 1', 'List 2'], // to be translated
+      filterPlaceholder: 'Enter keyword', // to be translated
+      noCheckedFormat: '{total} items', // to be translated
+      hasCheckedFormat: '{checked}/{total} checked' // to be translated
     }
   }
 };

+ 8 - 0
src/locale/lang/pt.js

@@ -90,6 +90,14 @@ export default {
     },
     tree: {
       emptyText: 'Sem dados'
+    },
+    transfer: {
+      noMatch: 'Sem correspondência',
+      noData: 'Sem dados',
+      titles: ['List 1', 'List 2'], // to be translated
+      filterPlaceholder: 'Enter keyword', // to be translated
+      noCheckedFormat: '{total} items', // to be translated
+      hasCheckedFormat: '{checked}/{total} checked' // to be translated
     }
   }
 };

+ 8 - 0
src/locale/lang/ru-RU.js

@@ -90,6 +90,14 @@ export default {
     },
     tree: {
       emptyText: 'Нет данных'
+    },
+    transfer: {
+      noMatch: 'Совпадений не найдено',
+      noData: 'Нет данных',
+      titles: ['List 1', 'List 2'], // to be translated
+      filterPlaceholder: 'Enter keyword', // to be translated
+      noCheckedFormat: '{total} items', // to be translated
+      hasCheckedFormat: '{checked}/{total} checked' // to be translated
     }
   }
 };

+ 12 - 0
src/locale/lang/sk.js

@@ -62,6 +62,10 @@ export default {
       noData: 'Žiadne dáta',
       placeholder: 'Vybrať'
     },
+    cascader: {
+      noMatch: 'Žiadna zhoda',
+      placeholder: 'Vybrať'
+    },
     pagination: {
       goto: 'Choď na',
       pagesize: 'na stranu',
@@ -88,6 +92,14 @@ export default {
     },
     tree: {
       emptyText: 'Žiadne dáta'
+    },
+    transfer: {
+      noMatch: 'Žiadna zhoda',
+      noData: 'Žiadne dáta',
+      titles: ['List 1', 'List 2'], // to be translated
+      filterPlaceholder: 'Enter keyword', // to be translated
+      noCheckedFormat: '{total} items', // to be translated
+      hasCheckedFormat: '{checked}/{total} checked' // to be translated
     }
   }
 };

+ 12 - 0
src/locale/lang/sv-SE.js

@@ -60,6 +60,10 @@ export default {
       noData: 'Ingen data',
       placeholder: 'Välj'
     },
+    cascader: {
+      noMatch: 'Hittade inget',
+      placeholder: 'Välj'
+    },
     pagination: {
       goto: 'Gå till',
       pagesize: '/sida',
@@ -86,6 +90,14 @@ export default {
     },
     tree: {
       emptyText: 'Inga Data'
+    },
+    transfer: {
+      noMatch: 'Hittade inget',
+      noData: 'Ingen data',
+      titles: ['List 1', 'List 2'], // to be translated
+      filterPlaceholder: 'Enter keyword', // to be translated
+      noCheckedFormat: '{total} items', // to be translated
+      hasCheckedFormat: '{checked}/{total} checked' // to be translated
     }
   }
 };

+ 8 - 0
src/locale/lang/th.js

@@ -90,6 +90,14 @@ export default {
     },
     tree: {
       emptyText: 'ไม่พบข้อมูล'
+    },
+    transfer: {
+      noMatch: 'ไม่พบข้อมูลที่ตรงกัน',
+      noData: 'ไม่พบข้อมูล',
+      titles: ['List 1', 'List 2'], // to be translated
+      filterPlaceholder: 'Enter keyword', // to be translated
+      noCheckedFormat: '{total} items', // to be translated
+      hasCheckedFormat: '{checked}/{total} checked' // to be translated
     }
   }
 };

+ 8 - 0
src/locale/lang/tk.js

@@ -90,6 +90,14 @@ export default {
     },
     tree: {
       emptyText: 'Maglumat ýok'
+    },
+    transfer: {
+      noMatch: 'Hiçzat tapylmady',
+      noData: 'Hiçzat ýok',
+      titles: ['List 1', 'List 2'], // to be translated
+      filterPlaceholder: 'Enter keyword', // to be translated
+      noCheckedFormat: '{total} items', // to be translated
+      hasCheckedFormat: '{checked}/{total} checked' // to be translated
     }
   }
 };

+ 8 - 0
src/locale/lang/tr-TR.js

@@ -90,6 +90,14 @@ export default {
     },
     tree: {
       emptyText: 'Veri yok'
+    },
+    transfer: {
+      noMatch: 'Eşleşen veri bulunamadı',
+      noData: 'Veri yok',
+      titles: ['List 1', 'List 2'], // to be translated
+      filterPlaceholder: 'Enter keyword', // to be translated
+      noCheckedFormat: '{total} items', // to be translated
+      hasCheckedFormat: '{checked}/{total} checked' // to be translated
     }
   }
 };

+ 8 - 0
src/locale/lang/ua.js

@@ -90,6 +90,14 @@ export default {
     },
     tree: {
       emptyText: 'Нема даних'
+    },
+    transfer: {
+      noMatch: 'Співпадінь не знайдено',
+      noData: 'Обрати',
+      titles: ['List 1', 'List 2'], // to be translated
+      filterPlaceholder: 'Enter keyword', // to be translated
+      noCheckedFormat: '{total} items', // to be translated
+      hasCheckedFormat: '{checked}/{total} checked' // to be translated
     }
   }
 };

+ 8 - 0
src/locale/lang/vi.js

@@ -90,6 +90,14 @@ export default {
     },
     tree: {
       emptyText: 'Không có dữ liệu'
+    },
+    transfer: {
+      noMatch: 'Dữ liệu không phù hợp',
+      noData: 'Không tìm thấy dữ liệu',
+      titles: ['List 1', 'List 2'], // to be translated
+      filterPlaceholder: 'Enter keyword', // to be translated
+      noCheckedFormat: '{total} items', // to be translated
+      hasCheckedFormat: '{checked}/{total} checked' // to be translated
     }
   }
 };

+ 8 - 0
src/locale/lang/zh-CN.js

@@ -90,6 +90,14 @@ export default {
     },
     tree: {
       emptyText: '暂无数据'
+    },
+    transfer: {
+      noMatch: '无匹配数据',
+      noData: '无数据',
+      titles: ['列表 1', '列表 2'],
+      filterPlaceholder: '请输入搜索内容',
+      noCheckedFormat: '共 {total} 项',
+      hasCheckedFormat: '已选 {checked}/{total} 项'
     }
   }
 };

+ 8 - 0
src/locale/lang/zh-TW.js

@@ -90,6 +90,14 @@ export default {
     },
     tree: {
       emptyText: '暫無資料'
+    },
+    transfer: {
+      noMatch: '無匹配資料',
+      noData: '無資料',
+      titles: ['List 1', 'List 2'], // to be translated
+      filterPlaceholder: 'Enter keyword', // to be translated
+      noCheckedFormat: '{total} items', // to be translated
+      hasCheckedFormat: '{checked}/{total} checked' // to be translated
     }
   }
 };

+ 124 - 0
test/unit/specs/transfer.spec.js

@@ -0,0 +1,124 @@
+import { createTest, createVue, destroyVM } from '../util';
+import Transfer from 'packages/transfer';
+
+describe('Transfer', () => {
+  let vm;
+  const getTestData = () => {
+    const data = [];
+    for (let i = 1; i <= 15; i++) {
+      data.push({
+        key: i,
+        label: `备选项 ${ i }`,
+        disabled: i % 4 === 0
+      });
+    }
+    return data;
+  };
+  const createTransfer = (props, opts) => {
+    return createVue(Object.assign({
+      template: `
+        <el-transfer :data="testData" ref="transfer" ${props}>
+        </el-transfer>
+      `,
+
+      created() {
+        this.testData = getTestData();
+      }
+    }, opts));
+  };
+
+  afterEach(() => {
+    destroyVM(vm);
+  });
+
+  it('create', () => {
+    vm = createTest(Transfer, true);
+    expect(vm.$el).to.exist;
+  });
+
+  it('default target list', () => {
+    vm = createTransfer('v-model="value"', {
+      data() {
+        return {
+          value: [1, 4]
+        };
+      }
+    });
+    expect(vm.$refs.transfer.sourceData.length).to.equal(13);
+  });
+
+  it('filterable', done => {
+    vm = createTransfer('v-model="value" filterable :filter-method="method"', {
+      data() {
+        return {
+          value: [],
+          method(query, option) {
+            return option.key === Number(query);
+          }
+        };
+      }
+    });
+    const transfer = vm.$refs.transfer;
+    const leftList = transfer.$el.querySelector('.el-transfer-panel').__vue__;
+    leftList.query = '1';
+    setTimeout(_ => {
+      expect(leftList.filteredData.length).to.equal(1);
+      done();
+    }, 50);
+  });
+
+  it('transfer', done => {
+    vm = createTransfer('v-model="value" :left-default-checked="[2, 3]" :right-default-checked="[1]"', {
+      data() {
+        return {
+          value: [1, 4]
+        };
+      }
+    });
+    const transfer = vm.$refs.transfer;
+
+    setTimeout(_ => {
+      transfer.addToLeft();
+      setTimeout(_ => {
+        expect(transfer.sourceData.length).to.equal(14);
+        transfer.addToRight();
+        setTimeout(_ => {
+          expect(transfer.sourceData.length).to.equal(12);
+          done();
+        }, 50);
+      }, 50);
+    }, 50);
+  });
+
+  it('customize', () => {
+    vm = createTransfer('v-model="value" :titles="titles" :render-content="renderFunc" :footer-format="format"', {
+      data() {
+        return {
+          value: [2],
+          titles: ['1', '2'],
+          format: { noChecked: 'no', hasChecked: 'has' },
+          renderFunc(h, option) {
+            return <span>{ option.key } - { option.label }</span>;
+          }
+        };
+      }
+    });
+    const transfer = vm.$refs.transfer.$el;
+    expect(transfer.querySelector('.el-transfer-panel__header').innerText).to.equal('1');
+    expect(transfer.querySelector('.el-checkbox__label span').innerText).to.equal('1 - 备选项 1');
+    expect(transfer.querySelector('.el-transfer-panel__footer .el-checkbox__label').innerText).to.equal('no');
+  });
+
+  it('check', () => {
+    vm = createTransfer('v-model="value"', {
+      data() {
+        return {
+          value: []
+        };
+      }
+    });
+    const leftList = vm.$refs.transfer.$el.querySelector('.el-transfer-panel').__vue__;
+    leftList.handleAllCheckedChange({ target: { checked: true } });
+    expect(leftList.checked.length).to.equal(12);
+  });
+});