输入类

穿梭框

一个更直观高效的多选选择器,可以露出更多选项的信息,支持搜索功能,缺点是占据更多空间

代码演示

如何引入

<script lang="ts">
    import { Transfer } from '@fibrae/semi-ui-svelte';
</script>

基本使用

数据项需传入 value、label、key

总个数:100
选项名称 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
已选个数:0
暂无内容,可从左侧勾选
代码
<script lang="ts">
    import { Transfer, type TransferDataItem, type TransferValue } from '@fibrae/semi-ui-svelte';

    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>

<Transfer dataSource={data} {onChange} style="width: 568px; height: 416px" />

分组

将 type 设为 groupList

分组的 dataSource,一级子元素必须拥有 title 以及 children 属性,结构参考 <GroupItem>

暂不支持多层嵌套

总个数:13
类别A
A-1
A-2
A-3
类别B
B-1
B-2
B-3(disabled)
类别C
C-1
C-2
C-3
C-4
C-5
C-6
C-7
已选个数:1
B-3(disabled)
代码
<script lang="ts">
    import { Transfer } from '@fibrae/semi-ui-svelte';

    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>

<Transfer dataSource={dataWithGroup} onChange={(values, items) => console.log(values, items)} style="width: 568px" type="groupList" value={[6]} />

自定义筛选逻辑,自定义选项数据渲染

使用filter自定义搜索逻辑,返回 true 时表示当前项符合筛选规则,保留当前项在列表中的显示,返回 false 则表示不符合,当前项会被隐藏。
当 type 为 treeList时,如需要自定义搜索逻辑,需设置 filter 为 true,并通过 treeProps 的 filterTreeNode 设置自定义的搜索函数。

使用 sourceItem snippet,你可以自定义左侧每一条源数据的渲染结构。例如结合 Highlight 组件高亮搜索匹配文本
使用 selectedItem snippet 你可以自定义右侧每一条已选项的渲染结构。
数据项自带的字段(如 demo 中的 abbr、color)都在 snippet 参数 item 上;只需要替换条目文本时,用 label snippet 更简单,它同时作用于左侧条目、右侧已选条目和拖拽副本。

总个数:6
夏
夏可漫
申
申悦
文
文嘉茂
曲
曲晨一
二
曲晨二
三
曲晨三
已选个数:2
夏
夏可漫
申
申悦
代码
<script lang="ts">
    import { IconClose } from '@fibrae/semi-icons-svelte';
    import { Avatar, Button, Checkbox, Highlight, Transfer, type AvatarColor, type TransferDataItem, type TransferValue } from '@fibrae/semi-ui-svelte';

    interface Person extends TransferDataItem {
        abbr: string;
        color: AvatarColor;
    }

    let searchText = $state('');

    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>

<Transfer dataSource={data} filter={customFilter} {inputProps} {onChange} onSearch={(value) => (searchText = value)} style="width: 568px" value={['xiakeman@example.com', 'shenyue@example.com']}>
    {#snippet sourceItem({ item, checked, onChange: onItemChange })}
        {@const person = item as Person}
        <div class="components-transfer-demo-source-item">
            <Checkbox {checked} onChange={onItemChange} style="height: 52px; align-items: center">
                <Avatar color={person.color} size="small">{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>
    {/snippet}
    {#snippet selectedItem({ item, onRemove })}
        {@const person = item as Person}
        <div class="components-transfer-demo-selected-item">
            <Avatar color={person.color} size="small">{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" onclick={onRemove}>
                {#snippet icon()}<IconClose />{/snippet}
            </Button>
        </div>
    {/snippet}
</Transfer>
.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);
    }
}

禁用

总个数:20
选项名称 0
选项名称 1
选项名称 2
选项名称 3
选项名称 4
选项名称 5
选项名称 6
选项名称 7
选项名称 8
选项名称 9
选项名称 10
选项名称 11
选项名称 12
选项名称 13
选项名称 14
选项名称 15
选项名称 16
选项名称 17
选项名称 18
选项名称 19
已选个数:2
选项名称 2
选项名称 4
代码
<script lang="ts">
    import { Transfer, type TransferDataItem, type TransferValue } from '@fibrae/semi-ui-svelte';

    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>

<Transfer dataSource={data} disabled {onChange} style="width: 568px; height: 416px" value={[2, 4]} />

拖拽排序

将 draggable设为 true,开启拖拽排序功能。

总个数:30
选项名称 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
已选个数:2
选项名称 2
选项名称 4
代码
<script lang="ts">
    import { Transfer } from '@fibrae/semi-ui-svelte';

    const data = Array.from({ length: 30 }, (_, i) => ({
        label: `选项名称 ${i}`,
        value: i,
        disabled: false,
        key: `key-${i}`,
    }));
</script>

<Transfer dataSource={data} draggable onChange={(values, items) => console.log(values, items)} style="width: 568px; height: 416px" value={[2, 4]} />

左侧分页

当左侧选项较多时,可以通过 pagination 属性开启分页功能。

pagination 接收一个对象,包含以下属性:

  • pageSize: 每页显示的条数,默认为 10
  • currentPage: 当前页码(受控模式)
  • defaultCurrentPage: 默认当前页码(非受控模式)
  • onPageChange: 页码变化时的回调
总个数:100
选项名称 0
选项名称 1
选项名称 2
选项名称 3
选项名称 4
选项名称 5
选项名称 6
选项名称 7
选项名称 8
选项名称 9
已选个数:0
暂无内容,可从左侧勾选
代码
<script lang="ts">
    import { Transfer } from '@fibrae/semi-ui-svelte';

    const data = Array.from({ length: 100 }, (_, i) => ({
        label: `选项名称 ${i}`,
        value: i,
        disabled: false,
        key: `key-${i}`,
    }));
</script>

<Transfer dataSource={data} onChange={(values, items) => console.log(values, items)} pagination={{ pageSize: 10 }} style="width: 568px; height: 416px" />

左侧分页 + 受控页码

通过 pagination.currentPage 可以受控地设置当前页码。

当前页码: 1
总个数:100
选项名称 0
选项名称 1
选项名称 2
选项名称 3
选项名称 4
选项名称 5
选项名称 6
选项名称 7
选项名称 8
选项名称 9
已选个数:0
暂无内容,可从左侧勾选
代码
<script lang="ts">
    import { Button, ButtonGroup, Transfer } from '@fibrae/semi-ui-svelte';

    let currentPage = $state(1);
    const data = Array.from({ length: 100 }, (_, i) => ({
        label: `选项名称 ${i}`,
        value: i,
        disabled: false,
        key: `key-${i}`,
    }));
</script>

<div>
    <ButtonGroup style="margin-bottom: 12px">
        <Button onclick={() => (currentPage = 1)}>第1页</Button>
        <Button onclick={() => (currentPage = 2)}>第2页</Button>
        <Button onclick={() => (currentPage = 5)}>第5页</Button>
        <Button onclick={() => (currentPage = 10)}>第10页</Button>
    </ButtonGroup>
    <div>当前页码: {currentPage}</div>
    <Transfer dataSource={data} onChange={(values, items) => console.log(values, items)} pagination={{ pageSize: 10, currentPage, onPageChange: (page) => (currentPage = page) }} style="width: 568px; height: 416px" />
</div>

拖拽 + 自定义已选项渲染

将 draggable 设为 true,开启拖拽排序功能;使用 selectedItem snippet 自定义右侧已选项渲染;
你可以将触发器定义为任意你想要的内容,并且添加样式。draggable 时 snippet 参数 handle 的类型是 Snippet<[Snippet]>:把触发器写成一个 snippet(如 grip),用 {@render handle?.(grip)} 包住它即可。grip 要声明在 <Transfer> 标签之外,写在标签内会被当成 Transfer 的属性。draggable 为 false 时不提供 handle。

总个数:6
夏
夏可漫
申
申悦
文
文嘉茂
曲
曲晨一
二
曲晨二
三
曲晨三
已选个数:2
夏
夏可漫
申
申悦
代码
<script lang="ts">
    import { IconClose, IconHandle } from '@fibrae/semi-icons-svelte';
    import { Avatar, Button, Checkbox, Transfer, type AvatarColor, type TransferDataItem, type TransferValue } from '@fibrae/semi-ui-svelte';

    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>

{#snippet grip()}
    <IconHandle class="semi-right-item-drag-handler" />
{/snippet}

<Transfer dataSource={data} draggable filter={customFilter} {inputProps} {onChange} style="width: 568px" value={['xiakeman@example.com', 'shenyue@example.com']}>
    {#snippet sourceItem({ item, checked, onChange: onItemChange })}
        {@const person = item as Person}
        <div class="components-transfer-demo-source-item">
            <Checkbox {checked} onChange={onItemChange} style="height: 52px; align-items: center">
                <Avatar color={person.color} size="small">{person.abbr}</Avatar>
                <div class="info">
                    <div class="name">{item.label}</div>
                    <div class="email">{item.value}</div>
                </div>
            </Checkbox>
        </div>
    {/snippet}
    {#snippet selectedItem({ item, handle, onRemove })}
        {@const person = item as Person}
        {#if handle}
            <div class="components-transfer-demo-selected-item">
                {@render handle(grip)}
                <Avatar color={person.color} size="small">{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" onclick={onRemove}>
                    {#snippet icon()}<IconClose />{/snippet}
                </Button>
            </div>
        {/if}
    {/snippet}
</Transfer>

自定义渲染面板头部信息

sourceHeader、selectedHeader snippet 允许用户自定义渲染左右两个面板的头部信息,snippet 参数类型分别为 TransferSourceHeaderSnippetProps、TransferSelectedHeaderSnippetProps:

type TransferSourceHeaderSnippetProps = {
    num: number; // 数据总数或筛选结果数目
    showButton: boolean; // 是否展示全选/取消全选按钮
    allChecked: boolean; // 当前数据是否已全选
    onAllClick: () => void; // 点击全选/取消全选按钮后应调用的函数
    leafOnlyNum?: number; // 仅在 type 为 treeList 时提供,表示叶子节点数量
};

type TransferSelectedHeaderSnippetProps = {
    num: number; // 已选中数据总数
    showButton: boolean; // 是否展示清空按钮
    onClear: () => void; // 点击清空按钮后应调用的函数
};

使用示例如下

共 30 项
选项名称 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
0 项已选
暂无内容,可从左侧勾选
代码
<script lang="ts">
    import { Button, Transfer } from '@fibrae/semi-ui-svelte';

    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; align-items: center';
</script>

<Transfer dataSource={data} style="width: 568px; height: 416px">
    {#snippet sourceHeader({ num, showButton, allChecked, onAllClick })}
        <div style={headerStyle}>
            <span>共 {num} 项</span>
            {#if showButton}
                <Button size="small" theme="borderless" type="tertiary" onclick={onAllClick}>
                    {allChecked ? '取消全选' : '全选'}
                </Button>
            {/if}
        </div>
    {/snippet}
    {#snippet selectedHeader({ num, showButton, onClear })}
        <div style={headerStyle}>
            <span>{num} 项已选</span>
            {#if showButton}
                <Button size="small" theme="borderless" type="tertiary" onclick={onClear}>清空</Button>
            {/if}
        </div>
    {/snippet}
</Transfer>

完全自定义渲染

Semi 提供了 sourcePanel、selectedPanel snippet,允许你完全自定义左右侧两个面板的渲染结构
通过该功能,你可以直接复用 Transfer 内部的逻辑能力,实现高度自定义样式结构的Transfer组件
sourcePanel 的参数类型为 TransferSourcePanelSnippetProps,你可以从中获取数据来渲染出你的 Panel 结构

interface TransferSourcePanelSnippetProps {
    value: Array<string | number>; // 所有选中项的 value
    loading: boolean | undefined; // 是否加载中
    noMatch: boolean; // 是否没有符合当前搜索值匹配的项
    filterData: Array<Item>; // 匹配当前搜索值的项
    sourceData: Array<Item>; // 所有项
    propsDataSource: Array<Item> | Array<GroupItem> | Array<TreeItem>; // Transfer 的 dataSource
    allChecked: boolean; // 是否全部选中
    showNumber: number; // 筛选结果数量
    inputValue: string; // input搜索框的值
    selectedItems: Map<string | number, Item>; // 所有已选项的集合,Map 的键是 item.key
    onSearch: (searchString: string) => void; // 搜索框变化时应调用的函数,入参为搜索值
    onAllClick: () => void; // 左侧全部按钮点击时应调用的函数
    onSelectOrRemove: (item: Item) => void; // 选择、删除单个选项时应调用的函数,入参应为当前操作item
    onSelect: (values: Array<string | number>) => void; // 受控批量选中,入参为选中项的 value
}

selectedPanel 的参数类型为 TransferSelectedPanelSnippetProps

interface TransferSelectedPanelSnippetProps {
    length: number; // 已选项的数量
    selectedData: Array<Item>; // 所有已选项集合
    onClear: () => void; // 点击清空时应调用的回调函数
    onRemove: (item: Item) => void; // 删除单个选项时应调用的函数
    onSortEnd: (payload: { oldIndex: number; newIndex: number }) => void; // 对结果重新排序时应调用的函数
}
门店列表
待选门店: 100

海底捞门店 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

已选同步门店: 0
暂无数据,请从左侧筛选
代码
<script lang="ts">
    import { IconSearch } from '@fibrae/semi-icons-svelte';
    import { Button, Input, Spin, Transfer, type TransferValue } from '@fibrae/semi-ui-svelte';

    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>

<Transfer class="component-transfer-demo-custom-panel" {dataSource} {onChange}>
    {#snippet 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="搜索门店" onChange={(value) => onSearch(value)} showClear style="width: 454px; margin: 12px 14px">
                    {#snippet prefix()}<IconSearch />{/snippet}
                </Input>
                <div class="panel-controls sp-font">
                    <span>待选门店: {filterData.length}</span>
                    <Button size="small" theme="borderless" onclick={onAllClick}>
                        {allChecked ? '取消全选' : '全选'}
                    </Button>
                </div>
                <div class="panel-list">
                    {#if loading}
                        <Spin />
                    {:else if noMatch}
                        <div class="empty sp-font">
                            {inputValue ? '无搜索结果' : '暂无内容'}
                        </div>
                    {:else}
                        {#each filterData as item (item.key)}
                            <div class="semi-transfer-item panel-item">
                                <p>{item.label}</p>
                                <Button class="panel-item-remove" size="small" theme="borderless" type="primary" onclick={() => onSelectOrRemove(item)}>
                                    {selectedItems.has(item.key) ? '删除' : '添加'}
                                </Button>
                            </div>
                        {/each}
                    {/if}
                </div>
            </div>
        </section>
    {/snippet}
    {#snippet 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" onclick={onClear}>清空</Button>
            </div>
            <div class="panel-main">
                {#if !selectedData.length}
                    <div class="empty sp-font">暂无数据,请从左侧筛选</div>
                {:else}
                    {#each selectedData as item (item.key)}
                        <div class="semi-transfer-item panel-item">
                            <p>{item.label}</p>
                            <Button class="panel-item-remove" size="small" theme="borderless" type="primary" onclick={() => onRemove(item)}>删除</Button>
                        </div>
                    {/each}
                {/if}
            </div>
        </section>
    {/snippet}
</Transfer>
.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;
        }
    }
}

完全自定义渲染 、 拖拽排序

在完全自定义渲染的场景下,由于拖拽区的渲染也已由你完全接管,因此你不声明 draggable 亦可。但你需要自行实现拖拽逻辑,你可以借助社区中的拖拽工具库 dnd-kit 快速实现功能。

另外,要支持拖拽排序,你需要在拖拽排序结束后,将 oldIndex、newIndex 作为入参,调用 onSortEnd

使用 dnd-kit 的示例如下,需要用到的核心依赖有 @dnd-kit/svelte 与 @dnd-kit/dom:DragDropProvider 提供拖拽上下文,每个已选项对应一个 @dnd-kit/dom/sortable 导出的 Sortable,参数如下

1. 作用:通过唯一标志 id 与当前位置 index 获取拖拽过程中必要信息
2. 核心输入参数:
    - id: 唯一标识,数字或者字符串
    - index: 该项在列表中的当前位置,列表变化后需同步更新
    - element: 可拖拽的 dom 节点
    - handle: 可选,拖拽手柄的元素,不传时整个节点都可拖拽
3. 第二个参数是 DragDropManager 实例,卸载组件时需要调用各 Sortable 的 destroy

示例用 {@attach} 在每一行上创建 Sortable 并在清理函数中销毁,用 bind:value 保存已选值。拖拽过程中的顺序由示例自己的 preview 状态驱动,所以去掉了 OptimisticSortingPlugin:它会自己移动 DOM 节点,与 Svelte 的 keyed each 冲突。

DragDropProvider 的 onDragOver 里按 source 与 target 调整预览顺序;onDragEnd 里用 event.operation.source 的 id 算出拖拽前后的位置,再调用 #selectedPanel 参数里的 onSortEnd;event.canceled 为 true 表示拖拽已取消。

门店列表
待选门店: 100

海底捞门店 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

海底捞门店 2

海底捞门店 4

代码
<script lang="ts">
    import { untrack, type ComponentProps } from 'svelte';
    import { DragDropManager } from '@dnd-kit/dom';
    import { isSortable, OptimisticSortingPlugin, Sortable } from '@dnd-kit/dom/sortable';
    import { DragDropProvider } from '@dnd-kit/svelte';
    import { IconHandle, IconSearch } from '@fibrae/semi-icons-svelte';
    import { Button, Input, Spin, Transfer, type TransferDataItem, type TransferOnSortEnd, type TransferValue } from '@fibrae/semi-ui-svelte';

    type ProviderProps = ComponentProps<typeof DragDropProvider>;

    const dataSource = Array.from({ length: 100 }, (_v, i) => ({
        label: `海底捞门店 ${i}`,
        value: i,
        disabled: false,
        key: `key-${i}`,
    }));

    let value = $state<TransferValue>([2, 4]);
    // The order shown while dragging; cleared on drop, so Transfer's value stays the only truth
    let preview = $state.raw<TransferDataItem[] | null>(null);

    // The default plugins keep the Accessibility one, which gives each handle a button role and Tab stop for keyboard sorting
    const manager = new DragDropManager();
    // OptimisticSortingPlugin moves DOM nodes itself, which collides with Svelte's keyed each; `preview` drives the order instead
    const sortablePlugins: ConstructorParameters<typeof Sortable>[0]['plugins'] = (defaults) => defaults.filter((plugin) => plugin !== OptimisticSortingPlugin);

    // Only the handle drags; it is found inside the row once the row's children are rendered
    const sortable = (id: string | number, position: () => number) => (row: HTMLElement) => {
        const item = new Sortable({ id, index: untrack(position), element: row, handle: row.querySelector<HTMLElement>('[data-sort-handle]') ?? undefined, plugins: sortablePlugins }, manager);
        $effect(() => {
            item.index = position();
        });
        return () => item.destroy();
    };

    const onDragOver = (selectedData: TransferDataItem[]): ProviderProps['onDragOver'] => {
        return ({ operation: { source, target } }) => {
            if (!isSortable(source) || !isSortable(target) || source.id === target.id) return;
            const next = [...(preview ?? selectedData)];
            const from = next.findIndex((item) => item.key === source.id);
            const to = next.findIndex((item) => item.key === target.id);
            if (from < 0 || to < 0) return;
            next.splice(to, 0, ...next.splice(from, 1));
            preview = next;
        };
    };

    const onDragEnd = (selectedData: TransferDataItem[], onSortEnd: TransferOnSortEnd): ProviderProps['onDragEnd'] => {
        return ({ canceled, operation: { source } }) => {
            if (!canceled && preview && source) {
                const oldIndex = selectedData.findIndex((item) => item.key === source.id);
                const newIndex = preview.findIndex((item) => item.key === source.id);
                if (oldIndex !== newIndex) onSortEnd({ oldIndex, newIndex });
            }
            preview = null;
        };
    };

    const onChange = (next: TransferValue) => console.log(next);
</script>

<Transfer bind:value class="component-transfer-demo-custom-panel" {dataSource} {onChange}>
    {#snippet 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="搜索门店" onChange={(text) => onSearch(text)} showClear style="width: 454px; margin: 12px 14px">
                    {#snippet prefix()}<IconSearch />{/snippet}
                </Input>
                <div class="panel-controls sp-font">
                    <span>待选门店: {filterData.length}</span>
                    <Button size="small" theme="borderless" onclick={onAllClick}>
                        {allChecked ? '取消全选' : '全选'}
                    </Button>
                </div>
                <div class="panel-list">
                    {#if loading}
                        <Spin />
                    {:else if noMatch}
                        <div class="empty sp-font">
                            {inputValue ? '无搜索结果' : '暂无内容'}
                        </div>
                    {:else}
                        {#each filterData as item (item.key)}
                            <div class="semi-transfer-item panel-item">
                                <div class="panel-item-main" style="flex-grow: 1">
                                    <p style="margin: 0 12px">{item.label}</p>
                                    <Button class="panel-item-remove" size="small" theme="borderless" type="primary" onclick={() => onSelectOrRemove(item)}>
                                        {selectedItems.has(item.key) ? '删除' : '添加'}
                                    </Button>
                                </div>
                            </div>
                        {/each}
                    {/if}
                </div>
            </div>
        </section>
    {/snippet}
    {#snippet selectedPanel({ selectedData, onClear, onRemove, onSortEnd })}
        {@const ordered = preview ?? selectedData}
        <section class="selected-panel">
            <div class="panel-header sp-font">
                <div>已选同步门店: {selectedData.length}</div>
                <Button size="small" theme="borderless" type="primary" onclick={onClear}>清空</Button>
            </div>
            <div class="panel-main" style="display: block">
                <DragDropProvider {manager} onDragEnd={onDragEnd(selectedData, onSortEnd)} onDragOver={onDragOver(selectedData)}>
                    <div style="overflow: auto; display: flex; flex-direction: column; row-gap: 8px">
                        <!-- Keyed by key: dnd-kit reorders the rows mid-drag, and the keyed each moves them with their data -->
                        {#each ordered as item, position (item.key)}
                            <div {@attach sortable(item.key, () => position)}>
                                <div class="semi-transfer-item panel-item">
                                    <span aria-label={String(item.label)} data-sort-handle style="line-height: 0">
                                        <IconHandle class="pane-item-drag-handler" />
                                    </span>
                                    <div class="panel-item-main" style="flex-grow: 1">
                                        <p style="margin: 0 12px">{item.label}</p>
                                        <Button class="panel-item-remove" size="small" theme="borderless" type="primary" onclick={() => onRemove(item)}>删除</Button>
                                    </div>
                                </div>
                            </div>
                        {/each}
                    </div>
                </DragDropProvider>
            </div>
        </section>
    {/snippet}
</Transfer>

树穿梭框

传入 type 为treeList,使用Tree组件作为自定义渲染列表。

可通过treeProps(TreeProps)来覆盖默认树的属性,左侧树默认属性为

{
    multiple:true,
    disableStrictly:true,
    leafOnly:true,
    filterTreeNode:true,
    defaultExpandAll:true,
}

Tree 自带的搜索框已隐藏,由 Transfer 自己的搜索框驱动过滤。Tree 的 snippet 中只有 label 经 Transfer 的 label snippet 转发,同时用于左侧树行和右侧已选项。叶子节点写 children: []。

总个数:12
  • Asia
  • China
  • Beijing
  • Shanghai
  • Chengdu
  • Japan
  • Osaka
  • North America
  • United States
  • Canada
  • Mexico
  • Cuba
已选个数:1
Shanghai
代码
<script lang="ts">
    import { Transfer, type TransferTreeItem, type TransferValue } from '@fibrae/semi-ui-svelte';

    const treeData: TransferTreeItem[] = [
        {
            label: 'Asia',
            value: 'Asia',
            key: '0',
            children: [
                {
                    label: 'China',
                    value: 'China',
                    key: '0-0',
                    children: [
                        { label: 'Beijing', value: 'Beijing', key: '0-0-0', children: [] },
                        { label: 'Shanghai', value: 'Shanghai', key: '0-0-1', children: [] },
                        { label: 'Chengdu', value: 'Chengdu', key: '0-0-2', children: [] },
                    ],
                },
                {
                    label: 'Japan',
                    value: 'Japan',
                    key: '0-1',
                    children: [{ label: 'Osaka', value: 'Osaka', key: '0-1-0', children: [] }],
                },
            ],
        },
        {
            label: 'North America',
            value: 'North America',
            key: '1',
            children: [
                { label: 'United States', value: 'United States', key: '1-0', children: [] },
                { label: 'Canada', value: 'Canada', key: '1-1', children: [] },
                { label: 'Mexico', value: 'Mexico', disabled: true, key: '1-2', children: [] },
                { label: 'Cuba', value: 'Cuba', key: '1-3', children: [] },
            ],
        },
    ];

    let value = $state<TransferValue>(['Shanghai']);
</script>

<div style="margin: 10px; padding: 10px; width: 600px">
    <Transfer bind:value dataSource={treeData} type="treeList" />
</div>

树穿梭框自定义头部显示叶子节点数量

当 type 为 treeList 时,sourceHeader 的 TransferSourceHeaderSnippetProps 参数中会额外提供 leafOnlyNum 字段,表示叶子节点的数量。这在文件选择等场景中非常有用,可以在头部只显示文件数量而不是包含文件夹的总数。

文件总数: 7 个文件 (总节点数:10)
  • 文件夹 1
  • 文件 1-1
  • 文件 1-2
  • 子文件夹 1
  • 文件 1-1-1
  • 文件 1-1-2
  • 文件夹 2
  • 文件 2-1
  • 文件 2-2
  • 独立文件
已选个数:0
暂无内容,可从左侧勾选
代码
<script lang="ts">
    import { Button, Transfer, type TransferValue } from '@fibrae/semi-ui-svelte';

    const treeData = [
        {
            label: '文件夹 1',
            value: 'folder1',
            key: 'folder1',
            children: [
                { label: '文件 1-1', value: 'file1-1', key: 'file1-1', children: [] },
                { label: '文件 1-2', value: 'file1-2', key: 'file1-2', children: [] },
                {
                    label: '子文件夹 1',
                    value: 'subfolder1',
                    key: 'subfolder1',
                    children: [
                        { label: '文件 1-1-1', value: 'file1-1-1', key: 'file1-1-1', children: [] },
                        { label: '文件 1-1-2', value: 'file1-1-2', key: 'file1-1-2', children: [] },
                    ],
                },
            ],
        },
        {
            label: '文件夹 2',
            value: 'folder2',
            key: 'folder2',
            children: [
                { label: '文件 2-1', value: 'file2-1', key: 'file2-1', children: [] },
                { label: '文件 2-2', value: 'file2-2', key: 'file2-2', children: [] },
            ],
        },
        { label: '独立文件', value: 'file3', key: 'file3', children: [] },
    ];

    let value = $state<TransferValue>([]);
</script>

<Transfer bind:value dataSource={treeData} style="width: 600px" type="treeList">
    {#snippet 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} 个文件
                {#if leafOnlyNum !== undefined}
                    <span style="color: var(--semi-color-text-2); margin-inline-start: 8px; font-size: 12px"> (总节点数:{num}) </span>
                {/if}
            </span>
            {#if showButton}
                <Button size="small" theme="borderless" type="tertiary" onclick={onAllClick}>
                    {allChecked ? '取消全选' : '全选'}
                </Button>
            {/if}
        </div>
    {/snippet}
</Transfer>

Accessibility

ARIA

  • 搜索框所在的容器是原生 search 元素(search 地标),名称取自语言包
  • 根元素带 aria-label 或 aria-labelledby 且未指定 role 时,自动加 role group
  • 左侧选项列表添加 role list,选项为 listitem;groupList 的每个分组是外层列表的一个 listitem,其中分组标题命名嵌套的 list,选项在嵌套列表里
  • 右侧选中列表添加 role list,选中项添加 role listitem;自定义渲染的左右条目外会包一层 role listitem 的元素

键盘交互

  • 默认过滤不区分大小写
  • 禁用时左右两个列表是 Tab 停靠点,用来用键盘滚动其中内容
  • draggable 的拖拽句柄是带名称的原生 button:Tab 聚焦句柄,空格拿起,方向键移动,空格放下;排序过程会通过读屏播报。鼠标拖拽不变
  • 拖拽排序所需的 dnd-kit 加载完成前,句柄是带 aria-disabled="true" 的占位

API 参考

Transfer

Props

属性说明类型默认值
dataSource数据源。type 为 list 时是数据项数组,groupList 时是分组数组,treeList 时是带 children 的树形数据(叶子节点写 children: [])。treeList 的左侧树只在传入新数组时更新,原地修改不会生效。TransferDataSource[]
disabled是否禁用。booleanfalse
draggable是否开启右侧已选项的拖拽排序。排序后 value 与 onChange 的数组顺序随之改变。booleanfalse
emptyLeft左侧没有数据时的提示。未提供时显示语言包文案,传 false 隐藏该空状态。Snippet | false-
emptyRight右侧没有已选项时的提示。未提供时显示语言包文案,传 false 隐藏该空状态。Snippet | false-
emptySearch搜索没有结果时的提示。未提供时显示语言包文案,传 false 隐藏该空状态。Snippet | false-
filter筛选逻辑。为 false 时不显示搜索框;传入函数时自定义匹配,返回 true 表示保留该项;默认按 label 不区分大小写匹配。treeList 想自定义搜索时需设为 true,并通过 treeProps 的 filterTreeNode 传入匹配函数。Typeboolean | ((searchValue: string, item: TransferDataItem) => boolean)-
inputProps传给搜索框 Input 的属性,可配置项参考 Input;值与 onChange 由 Transfer 占用,需要外部驱动搜索时调用 search 方法。TransferInputProps-
label数据项的内容,同时用于左侧条目(treeList 为树行)、右侧已选条目与拖拽副本;默认显示数据项的 label。TypeSnippet<[TransferLabelSnippetProps]>-
loading左侧选项是否正在加载。boolean-
onBlur焦点从 Transfer 内部移到外部时调用一次,参数为 FocusEvent;在搜索框、复选框、列表项、移除按钮、拖拽手柄之间移动不调用,窗口失焦(relatedTarget 为空)时调用。Type(event: FocusEvent) => void-
onChange已选中的值变化时调用,拖拽排序后也会调用。参数依次为 value 数组与对应的数据项数组。这是 Transfer 自己的回调,不是根元素的原生 change。Type(values: TransferValue, items: TransferDataItem[]) => void-
onDeselect取消勾选一项时调用,参数为该数据项。Type(item: TransferDataItem) => void-
onSearch搜索框输入内容变化时调用,参数为搜索值。search 方法驱动的搜索不调用。Type(searchValue: string) => void-
onSelect勾选一项时调用,参数为该数据项。Type(item: TransferDataItem) => void-
pagination左侧面板的分页配置,仅对 list 与 groupList 生效。onPageChange 在对象内。TransferPagination-
selectedHeader自定义右侧面板头部。TypeSnippet<[TransferSelectedHeaderSnippetProps]>-
selectedItem完全自定义右侧单个已选项。draggable 时 handle 是 Snippet<[Snippet]>:把拖拽图标写成一个 snippet,用 {@render handle?.(grip)} 包住它。treeList 且 showPath 时 fullPath 为完整路径。TypeSnippet<[TransferSelectedItemSnippetProps]>-
selectedPanel完全自定义右侧已选面板。onSortEnd 在自行实现拖拽排序结束后调用。TypeSnippet<[TransferSelectedPanelSnippetProps]>-
showPath仅 treeList 有效,控制右侧已选项是否显示从根节点到自身的选择路径。booleanfalse
sourceHeader自定义左侧面板头部。leafOnlyNum 仅 treeList 提供。TypeSnippet<[TransferSourceHeaderSnippetProps]>-
sourceItem完全自定义左侧单个候选项。index 是过滤与分页后可见条目中的下标,不是在 dataSource 中的位置。TypeSnippet<[TransferSourceItemSnippetProps]>-
sourcePanel完全自定义左侧候选面板,可复用 Transfer 的筛选与选择逻辑。TypeSnippet<[TransferSourcePanelSnippetProps]>-
treeProps仅 treeList 有效,传给左侧 Tree 的属性,会覆盖默认的 multiple、disableStrictly、leafOnly、filterTreeNode、defaultExpandAll。选中值、树数据由 Transfer 决定,value、onChange、keyMaps、treeData、treeDataSimpleJson、onChangeWithObject 会被忽略;Tree 的 snippet 只转发 label。TransferTreeProps-
type穿梭框类型,可选 list、groupList、treeList。TransferType"list"
value已选中项的 value 数组,可用 bind:value 绑定。只传值时作为初始选中项,之后由组件自己维护;bind:value={get, set} 使其完全受控,数组顺序就是右侧的显示顺序。TransferValue-
virtualize右侧已选列表虚拟化,仅在使用默认右侧面板且 draggable 为 false 时生效。height 为数字时直接生效,为字符串(如 100%)时按面板剩余高度测量。TransferVirtualize-

Methods

方法说明类型
search按输入了该值来搜索;不调用 onSearch。Type(input: string) => void

class、style、aria-* 等未声明的属性落在 Transfer 根元素上;根元素带 aria-label 或 aria-labelledby 且未指定 role 时为 role="group"。value 可以用 bind:value 绑定,也可以只传值作为初始选中项,没有 defaultValue。搜索框的属性用 inputProps 传入,treeList 左侧 Tree 的属性用 treeProps 传入。

下文各接口在 @fibrae/semi-ui-svelte 中导出的类型名为:Item 对应 TransferDataItem,GroupItem 对应 TransferGroupItem,TreeItem 对应 TransferTreeItem,VirtualizeProps 对应 TransferVirtualize,PaginationProps 对应 TransferPagination。

Item Interface

数据项还可以带任意自定义字段,它们会原样出现在 snippet 参数与回调载荷的 item 上。

属性说明类型默认值
disabled是否禁用booleanfalse
key必填,每个选项的唯一标识,不允许重复string | number
label选项展示文本,默认过滤按它匹配;需要其他内容时用 label snippetstring
fullPath当 type="treeList" 且 showPath 为 true 时,返回当前节点从根节点到自身的完整路径节点数组Array<Item>
value选项代表的值string | number

GroupItem Interface

分组本身不是数据项,只有以下属性

属性说明类型默认值
children该分组的元素Array<Item>
title分组名称string

TreeItem Interface

TreeItem 继承 Item 的所有属性

属性说明类型默认值
children子元素Array<TreeItem>

VirtualizeProps Interface

属性说明类型默认值
height虚拟列表高度,传入 number 则直接生效,传入 string(如 100%)则根据剩余高度自适应number | string
width虚拟列表宽度number | string
itemSize每行高度(固定)number

PaginationProps Interface

属性说明类型默认值
currentPage当前页码(受控模式)number
defaultCurrentPage默认当前页码(非受控模式)number1
pageSize每页显示条数number10
onPageChange页码变化时的回调(currentPage: number) => void

Methods

Transfer 通过 export function 暴露 search,用 bind:this 取得实例后调用,类型为 TransferHandle。

<script lang="ts">
    import { Transfer, type TransferHandle } from '@fibrae/semi-ui-svelte';

    let transfer = $state<TransferHandle>();
</script>

<Transfer bind:this={transfer} />
<button onclick={() => transfer?.search('选项名称 1')}>搜索</button>

设计变量

下表是组件内部的 Sass 变量,不能覆写。定制样式请覆写 --semi-* CSS 变量(见 Design Token 页),或写不分层的 CSS 规则。
Sass 变量值用法
$transition_duration_transfer_item-bgvar(--semi-transition_duration-none)穿梭框条目-背景色-动画持续时间
$transition_function_transfer_item-bgvar(--semi-transition_function-easeIn)穿梭框条目-背景色-过渡曲线
$transition_delay_transfer_item-bgvar(--semi-transition_delay-none)穿梭框条目-背景色-延迟时间
$color-transfer-bgvar(--semi-color-bg-1)穿梭框背景颜色 - 默认
$color-transfer-bordervar(--semi-color-border)穿梭框描边颜色 - 默认
$color-transfer_header-textvar(--semi-color-text-2)穿梭框 header 文字颜色
$color-transfer_empty-textvar(--semi-color-text-2)穿梭框空状态文字颜色
$color-transfer_group_title-textvar(--semi-color-text-2)穿梭框分组标题文字颜色
$color-transfer_disabled-textvar(--semi-color-disabled-text)穿梭框禁用文本文字颜色
$color-transfer_close_icon-iconvar(--semi-color-text-2)穿梭框关闭按钮颜色
$color-transfer_item-textvar(--semi-color-text-1)穿梭框常规文字颜色
$color-transfer_selected_item-textvar(--semi-color-text-0)穿梭框选中条目文字颜色
$color-transfer_item-bg-hovervar(--semi-color-fill-0)穿梭框条目背景色 - 悬浮
$color-transfer_item-bg-activevar(--semi-color-fill-1)穿梭框条目背景色 - 按下
$width-transfer-border1px穿梭框描边宽度
$width-transfer-minWidth402px穿梭框整体最小宽度
$height-transfer400px穿梭框高度
$height-transfer_header24px穿梭框 header 高度
$height-transfer_item-minHeight36px穿梭框条目最小高度
$width-transfer_item_close-icon$width-icon-small穿梭框选中条目删除按钮宽度
$width-transfer_left50%穿梭框左侧面板宽度
$width-transfer_right50%穿梭框左侧面板宽度
$height-transfer_left_empty36px穿梭框空状态高度
$width-transfer_left-border$border-thickness-control穿梭框双侧面板分割线宽度
$height-transfer_right_header32px穿梭框 header 高度
$height-transfer_right_empty100%穿梭框右侧空状态宽度
$width-transfer_empty100%穿梭框整体空状态宽度
$height-transfer_group_title28px穿梭框分组标题高度
$spacing-transfer_header-marginTop12px穿梭框 header 顶部外边距
$spacing-transfer_header-marginRight12px穿梭框 header 右侧外边距
$spacing-transfer_header-marginBottom8px穿梭框 header 底部外边距
$spacing-transfer_header-marginLeft12px穿梭框 header 左侧外边距
$spacing-transfer_header_all-marginLeft$spacing-base穿梭框全选按钮左侧外边距
$spacing-transfer_item-paddingTop$spacing-tight穿梭框条目顶部内边距
$spacing-transfer_item-paddingRight9px穿梭框条目右侧内边距
$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-marginBottom0穿梭框右侧面板 header 底部外边距
$spacing-transfer_right_item_drag_handler-marginRight4px穿梭框拖动图标右侧外边距
$spacing-transfer_filter-marginTop12px穿梭框搜索顶部外边距
$spacing-transfer_filter-marginRight12px穿梭框搜索右侧外边距
$spacing-transfer_filter-marginBottom0穿梭框搜索底部外边距
$spacing-transfer_filter-marginLeft12px穿梭框搜索左侧外边距
$spacing-transfer_group_title-paddingLeft$spacing-base-tight穿梭框分组标题左侧内边距
$radius-transfervar(--semi-border-radius-medium)穿梭框圆角
$font-transfer_header_all-fontWeight600穿梭框字重