Demos
How to import
<script setup lang="ts">
import { Transfer } from '@fibrous/semi-ui-vue';
</script>
Basic Usage
dataSource should have value、label、key.
Code
<script setup lang="ts">
import { Transfer, type TransferDataItem, type TransferValue } from '@fibrous/semi-ui-vue';
const data = Array.from({ length: 100 }, (_v, i) => ({
label: `选项名称 ${i}`,
value: i,
disabled: false,
key: `key-${i}`,
}));
const onChange = (values: TransferValue, items: TransferDataItem[]) => console.log(values, items);
</script>
<template>
<Transfer :dataSource="data" :style="{ width: '568px', height: '416px' }" @change="onChange" />
</template>Grouped
Set type to groupList
For grouped dataSource, the first-level child elements must have title and children attributes, structure reference <GroupItem>
Does not support multi-level nesting
Code
<script setup lang="ts">
import { Transfer } from '@fibrous/semi-ui-vue';
const dataWithGroup = [
{
title: '类别A',
children: [
{ label: 'A-1', value: 1, disabled: false, key: 1 },
{ label: 'A-2', value: 2, disabled: false, key: 2 },
{ label: 'A-3', value: 3, disabled: false, key: 3 },
],
},
{
title: '类别B',
children: [
{ label: 'B-1', value: 4, disabled: false, key: 4 },
{ label: 'B-2', value: 5, disabled: false, key: 5 },
{ label: 'B-3(disabled)', value: 6, disabled: true, key: 6 },
],
},
{
title: '类别C',
children: [
{ label: 'C-1', value: 7, disabled: false, key: 7 },
{ label: 'C-2', value: 8, disabled: false, key: 8 },
{ label: 'C-3', value: 9, disabled: false, key: 9 },
{ label: 'C-4', value: 10, disabled: false, key: 10 },
{ label: 'C-5', value: 11, disabled: false, key: 11 },
{ label: 'C-6', value: 12, disabled: false, key: 12 },
{ label: 'C-7', value: 13, disabled: false, key: 13 },
],
},
];
</script>
<template>
<Transfer :dataSource="dataWithGroup" :defaultValue="[6]" style="width: 568px" type="groupList" @change="(values, items) => console.log(values, items)" />
</template>Custom filtering logic, custom option data rendering
Use filter to customize the search logic. When it returns true, it means that the current item meets the filter rules and keeps the display of the current item in the list. If it returns false, it means it does not match, and the current item will be hidden.
When type is treeList, if you need to customize search logic, you need to set filter to true and set a custom search function through filterTreeNode of treeProps.
Using the #sourceItem slot, you can customize the rendering structure of each source data on the left.
Using the #selectedItem slot you can customize the rendering structure of each selected item on the right.
The custom fields of an item (such as abbr and color in the demo) are all on the item slot parameter. To replace only the text of the rows, the #label slot is simpler: it applies to the source row, the selected row and the drag copy.
Code
<script setup lang="ts">
import { ref } from 'vue';
import { IconClose } from '@fibrous/semi-icons-vue';
import { Avatar, Button, Checkbox, Highlight, Transfer, type AvatarColor, type TransferDataItem, type TransferValue } from '@fibrous/semi-ui-vue';
interface Person extends TransferDataItem {
abbr: string;
color: AvatarColor;
}
const searchText = ref('');
const customFilter = (searchValue: string, item: TransferDataItem) => String(item.value).includes(searchValue) || String(item.label).includes(searchValue);
const data = [
{ label: '夏可漫', value: 'xiakeman@example.com', abbr: '夏', color: 'amber', area: 'US', key: 1 },
{ label: '申悦', value: 'shenyue@example.com', abbr: '申', color: 'indigo', area: 'UK', key: 2 },
{ label: '文嘉茂', value: 'wenjiamao@example.com', abbr: '文', color: 'cyan', area: 'HK', key: 3 },
{ label: '曲晨一', value: 'quchenyi@example.com', abbr: '曲', color: 'blue', area: 'India', key: 4 },
{ label: '曲晨二', value: 'quchener@example.com', abbr: '二', color: 'blue', area: 'India', key: 5 },
{ label: '曲晨三', value: 'quchensan@example.com', abbr: '三', color: 'blue', area: 'India', key: 6 },
] satisfies Person[];
const inputProps = { placeholder: '搜索姓名或邮箱' };
const onChange = (values: TransferValue, items: TransferDataItem[]) => console.log(values, items);
</script>
<template>
<Transfer :dataSource="data" :defaultValue="['xiakeman@example.com', 'shenyue@example.com']" :filter="customFilter" :inputProps="inputProps" :style="{ width: '568px' }" @change="onChange" @search="searchText = $event">
<template #sourceItem="{ item, checked, onChange: onItemChange }">
<div class="components-transfer-demo-source-item">
<Checkbox :modelValue="checked" :style="{ height: '52px', alignItems: 'center' }" @change="onItemChange">
<Avatar :color="(item as Person).color" size="small">{{ (item as Person).abbr }}</Avatar>
<div class="info">
<div class="name">
<Highlight :searchWords="[searchText]" :sourceString="String(item.label)" />
</div>
<div class="email">
<Highlight :searchWords="[searchText]" :sourceString="String(item.value)" />
</div>
</div>
</Checkbox>
</div>
</template>
<template #selectedItem="{ item, onRemove }">
<div class="components-transfer-demo-selected-item">
<Avatar :color="(item as Person).color" size="small">{{ (item as Person).abbr }}</Avatar>
<div class="info">
<div class="name">{{ item.label }}</div>
<div class="email">{{ item.value }}</div>
</div>
<Button :aria-label="`移除 ${item.label}`" class="remove" size="small" theme="borderless" type="tertiary" @click="onRemove">
<template #icon>
<IconClose />
</template>
</Button>
</div>
</template>
</Transfer>
</template>.components-transfer-demo-selected-item {
.remove {
opacity: 0;
}
&:hover,
&:focus-within {
.remove {
opacity: 1;
}
}
}
.components-transfer-demo-selected-item,
.components-transfer-demo-source-item {
height: 52px;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
&:hover {
background-color: var(--semi-color-fill-0);
}
.info {
margin-left: 8px;
flex-grow: 1;
}
.name {
font-size: 14px;
line-height: 20px;
}
.email {
font-size: 12px;
line-height: 16px;
color: var(--semi-color-text-2);
}
}
Disabled
Code
<script setup lang="ts">
import { Transfer, type TransferDataItem, type TransferValue } from '@fibrous/semi-ui-vue';
const data = Array.from({ length: 20 }, (_v, i) => ({
label: `选项名称 ${i}`,
value: i,
disabled: false,
key: `key-${i}`,
}));
const onChange = (values: TransferValue, items: TransferDataItem[]) => console.log(values, items);
</script>
<template>
<Transfer :dataSource="data" :defaultValue="[2, 4]" disabled :style="{ width: '568px', height: '416px' }" @change="onChange" />
</template>Drag and drop sort
Set draggable to true to enable the drag sort function.
Code
<script setup lang="ts">
import { Transfer } from '@fibrous/semi-ui-vue';
const data = Array.from({ length: 30 }, (_, i) => ({
label: `选项名称 ${i}`,
value: i,
disabled: false,
key: `key-${i}`,
}));
</script>
<template>
<Transfer :dataSource="data" :defaultValue="[2, 4]" draggable style="width: 568px; height: 416px" @change="(values, items) => console.log(values, items)" />
</template>Left panel pagination
When there are many options on the left, you can enable pagination through the pagination prop.
pagination accepts an object with the following properties:
pageSize: Number of items per page, default is 10currentPage: Current page number (controlled mode)defaultCurrentPage: Default current page number (uncontrolled mode)onPageChange: Callback when page changes
Code
<script setup lang="ts">
import { Transfer } from '@fibrous/semi-ui-vue';
const data = Array.from({ length: 100 }, (_, i) => ({
label: `选项名称 ${i}`,
value: i,
disabled: false,
key: `key-${i}`,
}));
</script>
<template>
<Transfer :dataSource="data" :pagination="{ pageSize: 10 }" style="width: 568px; height: 416px" @change="(values, items) => console.log(values, items)" />
</template>Left panel pagination + controlled page
You can control the current page number through pagination.currentPage.
Code
<script setup lang="ts">
import { ref } from 'vue';
import { Transfer, ButtonGroup, Button } from '@fibrous/semi-ui-vue';
const currentPage = ref(1);
const data = Array.from({ length: 100 }, (_, i) => ({
label: `选项名称 ${i}`,
value: i,
disabled: false,
key: `key-${i}`,
}));
</script>
<template>
<div>
<ButtonGroup style="margin-bottom: 12px">
<Button @click="currentPage = 1">第1页</Button>
<Button @click="currentPage = 2">第2页</Button>
<Button @click="currentPage = 5">第5页</Button>
<Button @click="currentPage = 10">第10页</Button>
</ButtonGroup>
<div>当前页码: {{ currentPage }}</div>
<Transfer :dataSource="data" :pagination="{ pageSize: 10, currentPage, onPageChange: (page) => (currentPage = page) }" style="width: 568px; height: 416px" @change="(values, items) => console.log(values, items)" />
</div>
</template>Drag and drop + custom selected rendering
Set draggable to true to enable the drag and drop sorting function; use the #selectedItem slot to customize the rendering of the selected items on the right; You can define the trigger as any content you want and add styles. With draggable, the slot parameter handle is the drag handle component: wrap the trigger in <component :is="handle">.
Code
<script setup lang="ts">
import { IconClose, IconHandle } from '@fibrous/semi-icons-vue';
import { Avatar, Button, Checkbox, Transfer, type AvatarColor, type TransferDataItem, type TransferValue } from '@fibrous/semi-ui-vue';
interface Person extends TransferDataItem {
abbr: string;
color: AvatarColor;
}
const customFilter = (searchValue: string, item: TransferDataItem) => String(item.value).includes(searchValue) || String(item.label).includes(searchValue);
const data = [
{ label: '夏可漫', value: 'xiakeman@example.com', abbr: '夏', color: 'amber', area: 'US', key: 1 },
{ label: '申悦', value: 'shenyue@example.com', abbr: '申', color: 'indigo', area: 'UK', key: 2 },
{ label: '文嘉茂', value: 'wenjiamao@example.com', abbr: '文', color: 'cyan', area: 'HK', key: 3 },
{ label: '曲晨一', value: 'quchenyi@example.com', abbr: '曲', color: 'blue', area: 'India', key: 4 },
{ label: '曲晨二', value: 'quchener@example.com', abbr: '二', color: 'blue', area: 'India', key: 5 },
{ label: '曲晨三', value: 'quchensan@example.com', abbr: '三', color: 'blue', area: 'India', key: 6 },
] satisfies Person[];
const inputProps = { placeholder: '搜索姓名或邮箱' };
const onChange = (values: TransferValue, items: TransferDataItem[]) => console.log(values, items);
</script>
<template>
<Transfer :dataSource="data" :defaultValue="['xiakeman@example.com', 'shenyue@example.com']" draggable :filter="customFilter" :inputProps="inputProps" :style="{ width: '568px' }" @change="onChange">
<template #sourceItem="{ item, checked, onChange: onItemChange }">
<div class="components-transfer-demo-source-item">
<Checkbox :modelValue="checked" :style="{ height: '52px', alignItems: 'center' }" @change="onItemChange">
<Avatar :color="(item as Person).color" size="small">{{ (item as Person).abbr }}</Avatar>
<div class="info">
<div class="name">{{ item.label }}</div>
<div class="email">{{ item.value }}</div>
</div>
</Checkbox>
</div>
</template>
<template #selectedItem="{ item, handle, onRemove }">
<div v-if="handle" class="components-transfer-demo-selected-item">
<component :is="handle">
<IconHandle class="semi-right-item-drag-handler" />
</component>
<Avatar :color="(item as Person).color" size="small">{{ (item as Person).abbr }}</Avatar>
<div class="info">
<div class="name">{{ item.label }}</div>
<div class="email">{{ item.value }}</div>
</div>
<Button :aria-label="`移除 ${item.label}`" class="remove" size="small" theme="borderless" type="tertiary" @click="onRemove">
<template #icon>
<IconClose />
</template>
</Button>
</div>
</template>
</Transfer>
</template>Custom rendering header information in panel
The #sourceHeader and #selectedHeader slots allow users to customize the header information of the left and right panels. The slot parameter types are TransferSourceHeaderSlotProps and TransferSelectedHeaderSlotProps:
type TransferSourceHeaderSlotProps = {
num: number; // The total number of data or the number of filtered results
showButton: boolean; // Whether to show select all/unselect all buttons
allChecked: boolean; // Whether the current data has been selected
onAllClick: () => void; // Function that should be called after clicking the select/unselect all button
leafOnlyNum?: number; // Only provided when type is treeList, represents the number of leaf nodes
};
type TransferSelectedHeaderSlotProps = {
num: number; // The total number of selected data
showButton: boolean; // Whether to show the clear button
onClear: () => void; // Function that should be called after clicking the clear button
};
The example is as follows:
Code
<script setup lang="ts">
import { Button, Transfer } from '@fibrous/semi-ui-vue';
const data = Array.from({ length: 30 }, (_v, i) => ({
label: `选项名称 ${i}`,
value: i,
disabled: false,
key: `key-${i}`,
}));
const headerStyle = { margin: '10px 0 0 10px', height: '24px', display: 'flex', alignItems: 'center' };
</script>
<template>
<Transfer :dataSource="data" :style="{ width: '568px', height: '416px' }">
<template #sourceHeader="{ num, showButton, allChecked, onAllClick }">
<div :style="headerStyle">
<span>共 {{ num }} 项</span>
<Button v-if="showButton" size="small" theme="borderless" type="tertiary" @click="onAllClick">
{{ allChecked ? '取消全选' : '全选' }}
</Button>
</div>
</template>
<template #selectedHeader="{ num, showButton, onClear }">
<div :style="headerStyle">
<span>{{ num }} 项已选</span>
<Button v-if="showButton" size="small" theme="borderless" type="tertiary" @click="onClear"> 清空 </Button>
</div>
</template>
</Transfer>
</template>Fully custom rendering
Semi provides the #sourcePanel and #selectedPanel slots, allowing you to completely customize the rendering structure of the left and right panels. With this function, you can directly reuse the logic capabilities inside the Transfer to implement the Transfer component with a highly customized style structure
The parameter type of #sourcePanel is TransferSourcePanelSlotProps, from which you can get data to render your Panel structure
interface TransferSourcePanelSlotProps {
value: Array<string | number>; // value of all selected items
loading: boolean | undefined; // Whether loading
noMatch: boolean; // Whether there is no matching item that matches the current search value
filterData: Array<Item>; // items that match the current search value
sourceData: Array<Item>; // All items
propsDataSource: Array<Item> | Array<GroupItem> | Array<TreeItem>; // The dataSource of Transfer
allChecked: boolean; // Whether to select all
showNumber: number; // Filter the number of results
inputValue: string; // the value of the input search box
selectedItems: Map<string | number, Item>; // collection of all selected items, the key of the Map is item.key
onSearch: (searchString: string) => void; // The function that should be called when the search box changes, the input parameter is the search value
onAllClick: () => void; // The function that should be called when all the buttons on the left are clicked
onSelectOrRemove: (item: Item) => void; // The function that should be called when selecting or deleting a single option, the input should be the current operation item
onSelect: (values: Array<string | number>) => void; // controlled batch selection, the input is the values of the selected items
}
The parameter type of #selectedPanel is TransferSelectedPanelSlotProps
interface TransferSelectedPanelSlotProps {
length: number; // number of selected options
selectedData: Array<Item>; // All selected items collection
onClear: () => void; // The callback function that should be called when clicking to clear
onRemove: (item: Item) => void; // The function that should be called when deleting a single option
onSortEnd: (payload: { oldIndex: number; newIndex: number }) => void; // The function that should be called when reordering the results
}
海底捞门店 0
海底捞门店 1
海底捞门店 2
海底捞门店 3
海底捞门店 4
海底捞门店 5
海底捞门店 6
海底捞门店 7
海底捞门店 8
海底捞门店 9
海底捞门店 10
海底捞门店 11
海底捞门店 12
海底捞门店 13
海底捞门店 14
海底捞门店 15
海底捞门店 16
海底捞门店 17
海底捞门店 18
海底捞门店 19
海底捞门店 20
海底捞门店 21
海底捞门店 22
海底捞门店 23
海底捞门店 24
海底捞门店 25
海底捞门店 26
海底捞门店 27
海底捞门店 28
海底捞门店 29
海底捞门店 30
海底捞门店 31
海底捞门店 32
海底捞门店 33
海底捞门店 34
海底捞门店 35
海底捞门店 36
海底捞门店 37
海底捞门店 38
海底捞门店 39
海底捞门店 40
海底捞门店 41
海底捞门店 42
海底捞门店 43
海底捞门店 44
海底捞门店 45
海底捞门店 46
海底捞门店 47
海底捞门店 48
海底捞门店 49
海底捞门店 50
海底捞门店 51
海底捞门店 52
海底捞门店 53
海底捞门店 54
海底捞门店 55
海底捞门店 56
海底捞门店 57
海底捞门店 58
海底捞门店 59
海底捞门店 60
海底捞门店 61
海底捞门店 62
海底捞门店 63
海底捞门店 64
海底捞门店 65
海底捞门店 66
海底捞门店 67
海底捞门店 68
海底捞门店 69
海底捞门店 70
海底捞门店 71
海底捞门店 72
海底捞门店 73
海底捞门店 74
海底捞门店 75
海底捞门店 76
海底捞门店 77
海底捞门店 78
海底捞门店 79
海底捞门店 80
海底捞门店 81
海底捞门店 82
海底捞门店 83
海底捞门店 84
海底捞门店 85
海底捞门店 86
海底捞门店 87
海底捞门店 88
海底捞门店 89
海底捞门店 90
海底捞门店 91
海底捞门店 92
海底捞门店 93
海底捞门店 94
海底捞门店 95
海底捞门店 96
海底捞门店 97
海底捞门店 98
海底捞门店 99
Code
<script setup lang="ts">
import { IconSearch } from '@fibrous/semi-icons-vue';
import { Button, Input, Spin, Transfer, type TransferValue } from '@fibrous/semi-ui-vue';
const dataSource = Array.from({ length: 100 }, (_v, i) => ({
label: `海底捞门店 ${i}`,
value: i,
disabled: false,
key: `key-${i}`,
}));
const onChange = (values: TransferValue) => console.log(values);
</script>
<template>
<Transfer class="component-transfer-demo-custom-panel" :dataSource="dataSource" @change="onChange">
<template #sourcePanel="{ loading, noMatch, filterData, selectedItems, allChecked, onAllClick, inputValue, onSearch, onSelectOrRemove }">
<section class="source-panel">
<div class="panel-header sp-font">门店列表</div>
<div class="panel-main">
<Input aria-label="搜索门店" showClear :style="{ width: '454px', margin: '12px 14px' }" @change="onSearch">
<template #prefix>
<IconSearch />
</template>
</Input>
<div class="panel-controls sp-font">
<span>待选门店: {{ filterData.length }}</span>
<Button size="small" theme="borderless" @click="onAllClick">
{{ allChecked ? '取消全选' : '全选' }}
</Button>
</div>
<div class="panel-list">
<Spin v-if="loading" />
<div v-else-if="noMatch" class="empty sp-font">
{{ inputValue ? '无搜索结果' : '暂无内容' }}
</div>
<template v-else>
<div v-for="item in filterData" :key="item.key" class="semi-transfer-item panel-item">
<p>{{ item.label }}</p>
<Button class="panel-item-remove" size="small" theme="borderless" type="primary" @click="onSelectOrRemove(item)">
{{ selectedItems.has(item.key) ? '删除' : '添加' }}
</Button>
</div>
</template>
</div>
</div>
</section>
</template>
<template #selectedPanel="{ selectedData, onClear, onRemove }">
<section class="selected-panel">
<div class="panel-header sp-font">
<div>已选同步门店: {{ selectedData.length }}</div>
<Button size="small" theme="borderless" type="primary" @click="onClear">清空</Button>
</div>
<div class="panel-main">
<div v-if="!selectedData.length" class="empty sp-font">暂无数据,请从左侧筛选</div>
<template v-else>
<div v-for="item in selectedData" :key="item.key" class="semi-transfer-item panel-item">
<p>{{ item.label }}</p>
<Button class="panel-item-remove" size="small" theme="borderless" type="primary" @click="onRemove(item)"> 删除 </Button>
</div>
</template>
</div>
</section>
</template>
</Transfer>
</template>.component-transfer-demo-custom-panel {
.sp-font {
color: var(--semi-grey-9);
font-size: 12px;
font-weight: 500;
line-height: 20px;
}
.empty {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.panel-item {
flex-shrink: 0;
height: 56px;
border-radius: 4px;
padding: 8px 12px;
flex-wrap: wrap;
background-color: rgba(22, 24, 35, 0.03);
&-main {
flex-grow: 1;
}
p {
margin: 0 12px;
flex-basis: 100%;
}
.panel-item-remove {
cursor: pointer;
color: var(--semi-color-primary);
}
}
.panel-header {
padding: 10px 12px;
border: 1px solid rgba(22, 24, 35, 0.16);
border-radius: 4px 4px 0 0;
height: 38px;
box-sizing: border-box;
background-color: var(--semi-color-tertiary-light-default);
display: flex;
align-items: center;
justify-content: space-between;
.clear {
cursor: pointer;
color: var(--semi-color-primary);
}
}
.source-panel {
display: flex;
flex-direction: column;
width: 482px;
height: 353px;
.panel-main {
border: 1px solid var(--semi-color-border);
border-top: none;
.panel-list {
display: flex;
flex-wrap: wrap;
row-gap: 8px;
column-gap: 8px;
overflow-y: auto;
height: 214px;
margin-left: 12px;
margin-right: 12px;
padding-bottom: 8px;
}
}
.panel-controls {
margin: 10px 12px;
font-size: 12px;
line-height: 20px;
.semi-button {
margin-left: 8px;
font-size: 12px;
}
}
.panel-item {
width: 176px;
}
margin-right: 16px;
}
.selected-panel {
width: 200px;
height: 353px;
.panel-main {
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 12px;
border: 1px solid var(--semi-color-border);
border-top: none;
height: 323px;
box-sizing: border-box;
row-gap: 8px;
}
}
}
Fully custom rendering, drag and drop sorting
In a completely custom rendering scene, since the rendering of the drag area has also been completely taken over by you, you do not need to declare draggable. But you need to implement the drag and drop logic yourself, You can use the drag-and-drop tool library dnd-kit to quickly realize the function.
Besides, To support drag sorting, you need to call onSortEnd with oldIndex and newIndex as the input parameters after the drag sorting is over
Example using dnd-kit,The core dependency that needs to be used is @dnd-kit/dom. DragDropManager provides the drag and drop context, and every selected item gets a Sortable exported from @dnd-kit/dom/sortable. Its parameters are as follows (with @dnd-kit/vue, its useSortable takes the same id, index, element and handle)
1. Function: Obtain the necessary information during the drag and drop process through the unique id and the current index
2. Core input parameters:
- id: unique identifier, which can be a number or a string
- index: the current position of the item in the list; keep it up to date when the list changes
- element: the draggable dom node
- handle: optional, the drag handle element; the whole node is draggable when omitted
3. The second argument is the DragDropManager instance; call destroy on every Sortable and on the manager when the component unmounts
After dragging ends, in the dragend event of manager.monitor, use initialIndex and index of event.operation.source to get the positions before and after the drag; event.canceled being true means the drag was canceled.
The example keeps the selected values in v-model and writes the new order back to it once a drag ends. Without a value of your own, take onSortEnd from the parameters of #selectedPanel and call it instead.
海底捞门店 0
海底捞门店 1
海底捞门店 2
海底捞门店 3
海底捞门店 4
海底捞门店 5
海底捞门店 6
海底捞门店 7
海底捞门店 8
海底捞门店 9
海底捞门店 10
海底捞门店 11
海底捞门店 12
海底捞门店 13
海底捞门店 14
海底捞门店 15
海底捞门店 16
海底捞门店 17
海底捞门店 18
海底捞门店 19
海底捞门店 20
海底捞门店 21
海底捞门店 22
海底捞门店 23
海底捞门店 24
海底捞门店 25
海底捞门店 26
海底捞门店 27
海底捞门店 28
海底捞门店 29
海底捞门店 30
海底捞门店 31
海底捞门店 32
海底捞门店 33
海底捞门店 34
海底捞门店 35
海底捞门店 36
海底捞门店 37
海底捞门店 38
海底捞门店 39
海底捞门店 40
海底捞门店 41
海底捞门店 42
海底捞门店 43
海底捞门店 44
海底捞门店 45
海底捞门店 46
海底捞门店 47
海底捞门店 48
海底捞门店 49
海底捞门店 50
海底捞门店 51
海底捞门店 52
海底捞门店 53
海底捞门店 54
海底捞门店 55
海底捞门店 56
海底捞门店 57
海底捞门店 58
海底捞门店 59
海底捞门店 60
海底捞门店 61
海底捞门店 62
海底捞门店 63
海底捞门店 64
海底捞门店 65
海底捞门店 66
海底捞门店 67
海底捞门店 68
海底捞门店 69
海底捞门店 70
海底捞门店 71
海底捞门店 72
海底捞门店 73
海底捞门店 74
海底捞门店 75
海底捞门店 76
海底捞门店 77
海底捞门店 78
海底捞门店 79
海底捞门店 80
海底捞门店 81
海底捞门店 82
海底捞门店 83
海底捞门店 84
海底捞门店 85
海底捞门店 86
海底捞门店 87
海底捞门店 88
海底捞门店 89
海底捞门店 90
海底捞门店 91
海底捞门店 92
海底捞门店 93
海底捞门店 94
海底捞门店 95
海底捞门店 96
海底捞门店 97
海底捞门店 98
海底捞门店 99
海底捞门店 2
海底捞门店 4
Code
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { DragDropManager } from '@dnd-kit/dom';
import { Sortable, isSortable } from '@dnd-kit/dom/sortable';
import { IconHandle, IconSearch } from '@fibrous/semi-icons-vue';
import { Button, Input, Spin, Transfer, type TransferDataItem, type TransferValue } from '@fibrous/semi-ui-vue';
type SortableId = string | number;
const dataSource = Array.from({ length: 100 }, (_v, i) => ({
label: `海底捞门店 ${i}`,
value: i,
disabled: false,
key: `key-${i}`,
}));
const values = ref<TransferValue>([2, 4]);
// Items drag by value, falling back to key
const sortableId = (item: TransferDataItem): SortableId => item.value ?? item.key;
const manager = new DragDropManager();
const sortables = new Map<SortableId, Sortable>();
const itemElements = new Map<SortableId, HTMLElement>();
const handleElements = new Map<SortableId, HTMLElement>();
const bindElement = (elements: Map<SortableId, HTMLElement>, id: SortableId, element: unknown) => {
if (element instanceof HTMLElement) {
elements.set(id, element);
} else {
elements.delete(id);
}
};
// One Sortable per selected item, synced after the DOM updates when items change
const syncSortables = () => {
values.value.forEach((id, index) => {
const element = itemElements.get(id);
if (!element) {
return;
}
const sortable = sortables.get(id);
if (sortable) {
sortable.index = index;
} else {
sortables.set(id, new Sortable({ id, index, element, handle: handleElements.get(id) }, manager));
}
});
for (const [id, sortable] of sortables) {
if (!values.value.includes(id)) {
sortable.destroy();
sortables.delete(id);
}
}
};
// The selected order is the value array order, so write it back to v-model after a drop
const stopListening = manager.monitor.addEventListener('dragend', (event) => {
const { source } = event.operation;
if (event.canceled || !isSortable(source) || source.initialIndex === source.index) {
return;
}
const next = [...values.value];
const [moved] = next.splice(source.initialIndex, 1);
next.splice(source.index, 0, moved);
values.value = next;
});
watch(values, syncSortables, { flush: 'post' });
onMounted(syncSortables);
onBeforeUnmount(() => {
stopListening();
sortables.forEach((sortable) => sortable.destroy());
manager.destroy();
});
const onChange = (next: TransferValue) => console.log(next);
</script>
<template>
<Transfer v-model="values" class="component-transfer-demo-custom-panel" :dataSource="dataSource" @change="onChange">
<template #sourcePanel="{ loading, noMatch, filterData, selectedItems, allChecked, onAllClick, inputValue, onSearch, onSelectOrRemove }">
<section class="source-panel">
<div class="panel-header sp-font">门店列表</div>
<div class="panel-main">
<Input aria-label="搜索门店" showClear :style="{ width: '454px', margin: '12px 14px' }" @change="onSearch">
<template #prefix>
<IconSearch />
</template>
</Input>
<div class="panel-controls sp-font">
<span>待选门店: {{ filterData.length }}</span>
<Button size="small" theme="borderless" @click="onAllClick">
{{ allChecked ? '取消全选' : '全选' }}
</Button>
</div>
<div class="panel-list">
<Spin v-if="loading" />
<div v-else-if="noMatch" class="empty sp-font">
{{ inputValue ? '无搜索结果' : '暂无内容' }}
</div>
<template v-else>
<div v-for="item in filterData" :key="item.key" class="semi-transfer-item panel-item">
<div class="panel-item-main" :style="{ flexGrow: 1 }">
<p :style="{ margin: '0 12px' }">{{ item.label }}</p>
<Button class="panel-item-remove" size="small" theme="borderless" type="primary" @click="onSelectOrRemove(item)">
{{ selectedItems.has(item.key) ? '删除' : '添加' }}
</Button>
</div>
</div>
</template>
</div>
</div>
</section>
</template>
<template #selectedPanel="{ selectedData, onClear, onRemove }">
<section class="selected-panel">
<div class="panel-header sp-font">
<div>已选同步门店: {{ selectedData.length }}</div>
<Button size="small" theme="borderless" type="primary" @click="onClear">清空</Button>
</div>
<div class="panel-main" :style="{ display: 'block' }">
<div :style="{ overflow: 'auto', display: 'flex', flexDirection: 'column', rowGap: '8px' }">
<div v-for="item in selectedData" :key="item.key" :ref="(element) => bindElement(itemElements, sortableId(item), element)">
<div class="semi-transfer-item panel-item">
<!-- Only the handle drags; the Accessibility plugin adds a button role and Tab stop -->
<span :ref="(element) => bindElement(handleElements, sortableId(item), element)" :aria-label="String(item.label)" :style="{ lineHeight: 0 }">
<IconHandle class="pane-item-drag-handler" />
</span>
<div class="panel-item-main" :style="{ flexGrow: 1 }">
<p :style="{ margin: '0 12px' }">{{ item.label }}</p>
<Button class="panel-item-remove" size="small" theme="borderless" type="primary" @click="onRemove(item)"> 删除 </Button>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
</Transfer>
</template>Tree Transfer
The input type is treeList, and the Tree component is used as a custom rendering list.
The properties of the default tree can be overridden by treeProps(TreeProps). The default properties of the tree on the left are
{
multiple:true,
disableStrictly:true,
leafOnly:true,
filterTreeNode:true,
defaultExpandAll:true,
}
The Tree's own search box is hidden with an empty #search slot, and the search box of Transfer drives the filtering. Of the Tree slots only #label is forwarded, through the #label slot of Transfer, and it applies to both the tree rows on the left and the selected items on the right; the #icon, #expandIcon and #fullLabel slots of the Tree are not available.
- Asia
- China
- Beijing
- Shanghai
- Chengdu
- Japan
- Osaka
- North America
- United States
- Canada
- Mexico
- Cuba
Code
<script setup lang="ts">
import { ref } from 'vue';
import { Transfer } from '@fibrous/semi-ui-vue';
const treeData = [
{
label: 'Asia',
value: 'Asia',
key: '0',
children: [
{
label: 'China',
value: 'China',
key: '0-0',
children: [
{ label: 'Beijing', value: 'Beijing', key: '0-0-0' },
{ label: 'Shanghai', value: 'Shanghai', key: '0-0-1' },
{ label: 'Chengdu', value: 'Chengdu', key: '0-0-2' },
],
},
{
label: 'Japan',
value: 'Japan',
key: '0-1',
children: [{ label: 'Osaka', value: 'Osaka', key: '0-1-0' }],
},
],
},
{
label: 'North America',
value: 'North America',
key: '1',
children: [
{ label: 'United States', value: 'United States', key: '1-0' },
{ label: 'Canada', value: 'Canada', key: '1-1' },
{ label: 'Mexico', value: 'Mexico', disabled: true, key: '1-2' },
{ label: 'Cuba', value: 'Cuba', key: '1-3' },
],
},
];
const value = ref<Array<string | number>>(['Shanghai']);
</script>
<template>
<div style="margin: 10px; padding: 10px; width: 600px">
<Transfer v-model="value" :dataSource="treeData" type="treeList" />
</div>
</template>Tree Transfer with Custom Header Showing Leaf Node Count
When type is treeList, the TransferSourceHeaderSlotProps parameter of #sourceHeader provides an additional leafOnlyNum field, which represents the number of leaf nodes. This is useful in scenarios like file selection, where you can display only the file count instead of the total including folders.
- 文件夹 1
- 文件 1-1
- 文件 1-2
- 子文件夹 1
- 文件 1-1-1
- 文件 1-1-2
- 文件夹 2
- 文件 2-1
- 文件 2-2
- 独立文件
Code
<script setup lang="ts">
import { ref } from 'vue';
import { Transfer, Button } from '@fibrous/semi-ui-vue';
const treeData = [
{
label: '文件夹 1',
value: 'folder1',
key: 'folder1',
children: [
{ label: '文件 1-1', value: 'file1-1', key: 'file1-1' },
{ label: '文件 1-2', value: 'file1-2', key: 'file1-2' },
{
label: '子文件夹 1',
value: 'subfolder1',
key: 'subfolder1',
children: [
{ label: '文件 1-1-1', value: 'file1-1-1', key: 'file1-1-1' },
{ label: '文件 1-1-2', value: 'file1-1-2', key: 'file1-1-2' },
],
},
],
},
{
label: '文件夹 2',
value: 'folder2',
key: 'folder2',
children: [
{ label: '文件 2-1', value: 'file2-1', key: 'file2-1' },
{ label: '文件 2-2', value: 'file2-2', key: 'file2-2' },
],
},
{ label: '独立文件', value: 'file3', key: 'file3' },
];
const value = ref<Array<string | number>>([]);
</script>
<template>
<Transfer v-model="value" :dataSource="treeData" style="width: 600px" type="treeList">
<template #sourceHeader="{ num, leafOnlyNum, showButton, allChecked, onAllClick }">
<div style="display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; border-bottom: 1px solid var(--semi-color-border)">
<span>
<strong>文件总数:</strong>
{{ leafOnlyNum !== undefined ? leafOnlyNum : num }} 个文件
<span v-if="leafOnlyNum !== undefined" style="color: var(--semi-color-text-2); margin-inline-start: 8px; font-size: 12px"> (总节点数:{{ num }}) </span>
</span>
<Button v-if="showButton" size="small" theme="borderless" type="tertiary" @click="onAllClick">
{{ allChecked ? '取消全选' : '全选' }}
</Button>
</div>
</template>
</Transfer>
</template>Accessibility
ARIA
- The container of the search box is a native
searchelement (the search landmark), named from the locale - When the root element has
aria-labeloraria-labelledbyand norole, it getsrolegroup - Add
rolelistto the option list on the left andlistitemto its options; ingroupListevery group is alistitemof the outer list, and its title names the nestedlistthat holds the options - Add
rolelistto the selected list on the right, addrolelistitemto the selected item; custom rendered items on either side are wrapped in an element withrolelistitem
Keyboard
- The default filter ignores case
- When disabled, the two lists are Tab stops so the keyboard can scroll their content
- With
draggable, the drag handle is a nativebuttonwith a name: Tab focuses the handle, Space picks up, the arrow keys move, Space drops; the sort is announced to screen readers. Mouse dragging is unchanged - Until the dnd-kit needed for sorting has loaded, the handle is a placeholder with
aria-disabled="true"
API Reference
Transfer
Props
| Property | Description | Type | Default |
|---|---|---|---|
| modelValue | Array of the values of the selected items, bound with v-model. Passing it makes the component controlled and its order is the order on the right; undefined returns to uncontrolled. | TransferValue | undefined |
| defaultValue | The values selected initially, read once on mount. | TransferValue | semiDefault("Transfer", "defaultValue", []) |
| dataSource | Data source: an array of items for list, an array of groups for groupList, or tree data with children for treeList. The tree of a treeList only updates when a new array is passed, changes made in place are not seen. | TransferDataSource | semiDefault("Transfer", "dataSource", []) |
| type | Transfer type: list, groupList or treeList. | TransferType | semiDefault("Transfer", "type", "list") |
| disabled | Whether the whole transfer is disabled. | boolean | semiGlobalDefault("Transfer", "disabled") |
| draggable | Enables drag sorting of the selected items on the right. After a sort, update:modelValue and change carry the new order. | boolean | semiGlobalDefault("Transfer", "draggable") |
| loading | Whether the options on the left are loading. | boolean | semiGlobalDefault("Transfer", "loading") |
| showPath | treeList only: whether the selected items on the right show their path from the root. | boolean | semiDefault("Transfer", "showPath", false) |
| filter | Filtering logic. false hides the search box; a function replaces the default case-insensitive label match and returns true to keep an item. For a custom search in treeList, set it to true and pass the matching function as filterTreeNode in treeProps. | Typeboolean | ((searchValue: string, item: TransferDataItem) => boolean) | undefined |
| inputProps | Props passed to the search Input, see Input for the options. Transfer owns its value and change; to search from outside, call the search method. | TransferInputProps | semiGlobalDefault("Transfer", "inputProps") |
| treeProps | treeList only: props passed to the Tree on the left, overriding the defaults multiple, disableStrictly, leafOnly, filterTreeNode and defaultExpandAll. Transfer decides the selection, the tree data and the Tree ref, so modelValue, defaultValue, keyMaps, treeData, treeDataSimpleJson, changeWithObject, change listeners and ref are ignored; of the Tree slots only label is forwarded. | TransferTreeProps | semiGlobalDefault("Transfer", "treeProps") |
| pagination | Paging of the left panel, for list and groupList only. onPageChange lives inside the object. | TransferPagination | semiGlobalDefault("Transfer", "pagination") |
| virtualize | Virtualizes the selected list on the right, only with the default right panel and draggable off. A numeric height applies as is; a string such as 100% is measured from the remaining panel height. | TransferVirtualize | semiGlobalDefault("Transfer", "virtualize") |
Events
| Event | Description | Callback parameters |
|---|---|---|
| update:modelValue | Emitted when the selection changes; used by v-model. The payload is the array of values. | [values: TransferValue] |
| change | Emitted when the selection changes, including after a drag sort. The payload is the array of values and the array of their items; the values equal those of update:modelValue. It shadows the native event of the same name on the root element. | [values: TransferValue, items: TransferDataItem[]] |
| select | Emitted when an item is checked, with that item. It shadows the native event of the same name on the root element. | [item: TransferDataItem] |
| deselect | Emitted when an item is unchecked, with that item. | [item: TransferDataItem] |
| search | Emitted when the text of the search box changes, with the search value. A search driven by the search method does not emit it. | [searchValue: string] |
Slots
| Slot | Description | Scope parameters |
|---|---|---|
| label | Content of an item, used by the source row (the tree row in treeList), the selected row and the drag copy; the label of the item by default. | TransferLabelSlotProps |
| sourceItem | Replaces one item on the left. index is the position among the shown items after filtering and paging, not the position in dataSource. | TransferSourceItemSlotProps |
| selectedItem | Replaces one selected item on the right. With draggable, handle is the drag handle component to wrap the drag icon in. With treeList and showPath, fullPath holds the whole path. | TransferSelectedItemSlotProps |
| sourceHeader | Replaces the header of the left panel. leafOnlyNum is provided for treeList only. | TransferSourceHeaderSlotProps |
| selectedHeader | Replaces the header of the right panel. | TransferSelectedHeaderSlotProps |
| sourcePanel | Replaces the whole left panel, reusing the filtering and selection logic of Transfer. | TransferSourcePanelSlotProps |
| selectedPanel | Replaces the whole right panel. Call onSortEnd after a drag sort you implemented has finished. | TransferSelectedPanelSlotProps |
| emptyLeft | Shown when the left list has no data. The locale text appears when it is not provided; an empty slot hides the empty state. | any |
| emptyRight | Shown when nothing is selected. The locale text appears when it is not provided; an empty slot hides the empty state. | any |
| emptySearch | Shown when the search matches nothing. The locale text appears when it is not provided; an empty slot hides the empty state. | any |
Exposes
| Name | Description | Type |
|---|---|---|
| search | Searches as if the value were typed into the search box, and puts the value there; the search event is not emitted. | Type(value: string) => void |
class, style, aria-* and other attrs land on the root element of Transfer; with aria-label or aria-labelledby and no role, the root element gets role="group". Pass the props of the search box through inputProps, and those of the Tree on the left of a treeList through treeProps.
The types of the interfaces below are exported from @fibrous/semi-ui-vue as: TransferDataItem for Item, TransferGroupItem for GroupItem, TransferTreeItem for TreeItem, TransferVirtualize for VirtualizeProps and TransferPagination for PaginationProps.
Item Interface
An item can carry any custom fields; they appear unchanged on item in the slot parameters and event payloads.
| props | description | data type | default |
|---|---|---|---|
| disabled | Whether to disable | boolean | false |
| key | Required, unique identification of each option, no repetition is allowed | string | number | |
| label | Option display text, matched by the default filter; use the #label slot for other content | string | |
| fullPath | When type="treeList" and showPath is true, returns the full path node array from the root node to the current node | Array<Item> | |
| value | The value represented by the option | string | number |
GroupItem Interface
A group is not an item itself, it only has the following properties
| props | description | data type | default |
|---|---|---|---|
| children | Elements of the group | array<Item> | |
| title | Group Name | string |
TreeItem Interface
TreeItem inherits all the properties of Item
| props | description | data type | default |
|---|---|---|---|
| children | Children Items | array<TreeItem> |
VirtualizeProps Interface
| props | description | data type | default |
|---|---|---|---|
| height | Height of the virtualized list. If it is a number, it takes effect directly. If it is a string (e.g. 100%), it adapts to remaining height | number | string | |
| width | Width of the virtualized list | number | string | |
| itemSize | Fixed height of each item row | number |
PaginationProps Interface
| props | description | data type | default |
|---|---|---|---|
| currentPage | Current page number (controlled mode) | number | |
| defaultCurrentPage | Default current page number (uncontrolled mode) | number | 1 |
| pageSize | Number of items per page | number | 10 |
| onPageChange | Callback when page changes | (currentPage: number) => void |
Design Tokens
| Sass variable | Value | Usage |
|---|---|---|
| $transition_duration_transfer_item-bg | var(--semi-transition_duration-none) | 穿梭框条目-背景色-动画持续时间 |
| $transition_function_transfer_item-bg | var(--semi-transition_function-easeIn) | 穿梭框条目-背景色-过渡曲线 |
| $transition_delay_transfer_item-bg | var(--semi-transition_delay-none) | 穿梭框条目-背景色-延迟时间 |
| $color-transfer-bg | var(--semi-color-bg-1) | 穿梭框背景颜色 - 默认 |
| $color-transfer-border | var(--semi-color-border) | 穿梭框描边颜色 - 默认 |
| $color-transfer_header-text | var(--semi-color-text-2) | 穿梭框 header 文字颜色 |
| $color-transfer_empty-text | var(--semi-color-text-2) | 穿梭框空状态文字颜色 |
| $color-transfer_group_title-text | var(--semi-color-text-2) | 穿梭框分组标题文字颜色 |
| $color-transfer_disabled-text | var(--semi-color-disabled-text) | 穿梭框禁用文本文字颜色 |
| $color-transfer_close_icon-icon | var(--semi-color-text-2) | 穿梭框关闭按钮颜色 |
| $color-transfer_item-text | var(--semi-color-text-1) | 穿梭框常规文字颜色 |
| $color-transfer_selected_item-text | var(--semi-color-text-0) | 穿梭框选中条目文字颜色 |
| $color-transfer_item-bg-hover | var(--semi-color-fill-0) | 穿梭框条目背景色 - 悬浮 |
| $color-transfer_item-bg-active | var(--semi-color-fill-1) | 穿梭框条目背景色 - 按下 |
| $width-transfer-border | 1px | 穿梭框描边宽度 |
| $width-transfer-minWidth | 402px | 穿梭框整体最小宽度 |
| $height-transfer | 400px | 穿梭框高度 |
| $height-transfer_header | 24px | 穿梭框 header 高度 |
| $height-transfer_item-minHeight | 36px | 穿梭框条目最小高度 |
| $width-transfer_item_close-icon | $width-icon-small | 穿梭框选中条目删除按钮宽度 |
| $width-transfer_left | 50% | 穿梭框左侧面板宽度 |
| $width-transfer_right | 50% | 穿梭框左侧面板宽度 |
| $height-transfer_left_empty | 36px | 穿梭框空状态高度 |
| $width-transfer_left-border | $border-thickness-control | 穿梭框双侧面板分割线宽度 |
| $height-transfer_right_header | 32px | 穿梭框 header 高度 |
| $height-transfer_right_empty | 100% | 穿梭框右侧空状态宽度 |
| $width-transfer_empty | 100% | 穿梭框整体空状态宽度 |
| $height-transfer_group_title | 28px | 穿梭框分组标题高度 |
| $spacing-transfer_header-marginTop | 12px | 穿梭框 header 顶部外边距 |
| $spacing-transfer_header-marginRight | 12px | 穿梭框 header 右侧外边距 |
| $spacing-transfer_header-marginBottom | 8px | 穿梭框 header 底部外边距 |
| $spacing-transfer_header-marginLeft | 12px | 穿梭框 header 左侧外边距 |
| $spacing-transfer_header_all-marginLeft | $spacing-base | 穿梭框全选按钮左侧外边距 |
| $spacing-transfer_item-paddingTop | $spacing-tight | 穿梭框条目顶部内边距 |
| $spacing-transfer_item-paddingRight | 9px | 穿梭框条目右侧内边距 |
| $spacing-transfer_item-paddingBottom | $spacing-tight | 穿梭框条目底部内边距 |
| $spacing-transfer_item-paddingLeft | $spacing-base-tight | 穿梭框条目左侧内边距 |
| $spacing-transfer_right_header-marginTop | $spacing-base-tight | 穿梭框右侧面板 header 顶部外边距 |
| $spacing-transfer_right_header-marginBottom | 0 | 穿梭框右侧面板 header 底部外边距 |
| $spacing-transfer_right_item_drag_handler-marginRight | 4px | 穿梭框拖动图标右侧外边距 |
| $spacing-transfer_filter-marginTop | 12px | 穿梭框搜索顶部外边距 |
| $spacing-transfer_filter-marginRight | 12px | 穿梭框搜索右侧外边距 |
| $spacing-transfer_filter-marginBottom | 0 | 穿梭框搜索底部外边距 |
| $spacing-transfer_filter-marginLeft | 12px | 穿梭框搜索左侧外边距 |
| $spacing-transfer_group_title-paddingLeft | $spacing-base-tight | 穿梭框分组标题左侧内边距 |
| $radius-transfer | var(--semi-border-radius-medium) | 穿梭框圆角 |
| $font-transfer_header_all-fontWeight | 600 | 穿梭框字重 |