输入类

穿梭框

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

代码演示

如何引入

import { Transfer } from '@fibrous/semi-ui-react';

基本使用

数据项需传入 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
暂无内容,可从左侧勾选
代码
import { Transfer } from '@fibrous/semi-ui-react';

export default function TransferDemo() {
    const data = Array.from({ length: 100 }, (_v, i) => {
        return {
            label: `选项名称 ${i}`,
            value: i,
            disabled: false,
            key: `key-${i}`,
        };
    });
    return <Transfer style={{ width: 568, height: 416 }} dataSource={data} onChange={(values, items) => console.log(values, items)} />;
}

分组

将 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)
代码
import { Transfer } from '@fibrous/semi-ui-react';

export default function TransferDemo() {
    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 },
            ],
        },
    ];
    return <Transfer type="groupList" defaultValue={[6]} style={{ width: 568 }} dataSource={dataWithGroup} onChange={(values, items) => console.log(values, items)} />;
}

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

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

使用renderSourceItem,你可以自定义左侧每一条源数据的渲染结构。例如结合 Highlight 组件高亮搜索匹配文本
使用renderSelectedItem 你可以自定义右侧每一条已选项的渲染结构。

总个数:6
夏
夏可漫
申
申悦
文
文嘉茂
曲
曲晨一
二
曲晨二
三
曲晨三
已选个数:2
夏
夏可漫
申
申悦
代码
import { useState } from 'react';
import { IconClose } from '@fibrous/semi-icons-react';
import { Transfer, Button, Checkbox, Avatar, Highlight, type TransferDataItem, type TransferRenderSelectedItemProps, type TransferRenderSourceItemProps } from '@fibrous/semi-ui-react';

export default function TransferDemo() {
    const [searchText, setSearchText] = useState('');
    const renderSourceItem = (item: TransferRenderSourceItemProps) => {
        return (
            <div className="components-transfer-demo-source-item" key={item.key}>
                <Checkbox
                    onChange={() => {
                        item.onChange?.();
                    }}
                    checked={item.checked}
                    style={{ height: 52, alignItems: 'center' }}
                >
                    <Avatar color={item.color} size="small">
                        {item.abbr}
                    </Avatar>
                    <div className="info">
                        <div className="name">
                            <Highlight sourceString={String(item.label)} searchWords={[searchText]} />
                        </div>
                        <div className="email">
                            <Highlight sourceString={String(item.value)} searchWords={[searchText]} />
                        </div>
                    </div>
                </Checkbox>
            </div>
        );
    };

    const renderSelectedItem = (item: TransferRenderSelectedItemProps) => {
        return (
            <div className="components-transfer-demo-selected-item" key={item.key}>
                <Avatar color={item.color} size="small">
                    {item.abbr}
                </Avatar>
                <div className="info">
                    <div className="name">{item.label}</div>
                    <div className="email">{item.value}</div>
                </div>
                <Button className="remove" theme="borderless" type="tertiary" size="small" icon={<IconClose />} aria-label={`移除 ${item.label}`} onClick={item.onRemove} />
            </div>
        );
    };

    const customFilter = (searchValue: string, item: TransferDataItem) => {
        return 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 },
    ];

    return (
        <Transfer
            style={{ width: 568 }}
            dataSource={data}
            filter={customFilter}
            defaultValue={['xiakeman@example.com', 'shenyue@example.com']}
            renderSelectedItem={renderSelectedItem}
            renderSourceItem={renderSourceItem}
            inputProps={{ placeholder: '搜索姓名或邮箱' }}
            onSearch={(searchText) => setSearchText(searchText)}
            onChange={(values, items) => console.log(values, items)}
        />
    );
}
.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
代码
import { Transfer } from '@fibrous/semi-ui-react';
export default function TransferDemo() {
    const data = Array.from({ length: 20 }, (_v, i) => {
        return {
            label: `选项名称 ${i}`,
            value: i,
            disabled: false,
            key: `key-${i}`,
        };
    });
    return <Transfer style={{ width: 568, height: 416 }} disabled dataSource={data} defaultValue={[2, 4]} onChange={(values, items) => console.log(values, items)} />;
}

拖拽排序

将 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
代码
import { Transfer } from '@fibrous/semi-ui-react';
export default function TransferDemo() {
    const data = Array.from({ length: 30 }, (_v, i) => {
        return {
            label: `选项名称 ${i}`,
            value: i,
            disabled: false,
            key: `key-${i}`,
        };
    });
    return <Transfer style={{ width: 568, height: 416 }} dataSource={data} defaultValue={[2, 4]} draggable onChange={(values, items) => console.log(values, items)} />;
}

左侧分页

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

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

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

export default function TransferDemo() {
    const data = Array.from({ length: 100 }, (_v, i) => {
        return {
            label: `选项名称 ${i}`,
            value: i,
            disabled: false,
            key: `key-${i}`,
        };
    });
    return (
        <Transfer
            style={{ width: 568, height: 416 }}
            dataSource={data}
            pagination={{
                pageSize: 10,
            }}
            onChange={(values, items) => console.log(values, items)}
        />
    );
}

左侧分页 + 受控页码

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

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

export default function TransferDemo() {
    const [currentPage, setCurrentPage] = useState(1);
    const data = Array.from({ length: 100 }, (_v, i) => {
        return {
            label: `选项名称 ${i}`,
            value: i,
            disabled: false,
            key: `key-${i}`,
        };
    });
    return (
        <div>
            <ButtonGroup style={{ marginBottom: 12 }}>
                <Button onClick={() => setCurrentPage(1)}>第1页</Button>
                <Button onClick={() => setCurrentPage(2)}>第2页</Button>
                <Button onClick={() => setCurrentPage(5)}>第5页</Button>
                <Button onClick={() => setCurrentPage(10)}>第10页</Button>
            </ButtonGroup>
            <div>当前页码: {currentPage}</div>
            <Transfer
                style={{ width: 568, height: 416 }}
                dataSource={data}
                pagination={{
                    pageSize: 10,
                    currentPage,
                    onPageChange: (page) => setCurrentPage(page),
                }}
                onChange={(values, items) => console.log(values, items)}
            />
        </div>
    );
}

拖拽 + 自定义已选项渲染

将 draggable设为 true,开启拖拽排序功能;使用 renderSelectedItem 自定义右侧已选项渲染;
你可以将触发器定义为任意你想要的ReactNode,并且添加样式。将拖拽触发器,使用 sortableHandle 进行包裹即可,

总个数:6
夏
夏可漫
申
申悦
文
文嘉茂
曲
曲晨一
二
曲晨二
三
曲晨三
已选个数:2
夏
夏可漫
申
申悦
代码
import { IconHandle, IconClose } from '@fibrous/semi-icons-react';
import { Transfer, Button, Checkbox, Avatar, type TransferDataItem, type TransferRenderSelectedItemProps, type TransferRenderSourceItemProps } from '@fibrous/semi-ui-react';

export default function TransferDemo() {
    const renderSourceItem = (item: TransferRenderSourceItemProps) => {
        return (
            <div className="components-transfer-demo-source-item" key={item.key}>
                <Checkbox
                    onChange={() => {
                        item.onChange?.();
                    }}
                    checked={item.checked}
                    style={{ height: 52, alignItems: 'center' }}
                >
                    <Avatar color={item.color} size="small">
                        {item.abbr}
                    </Avatar>
                    <div className="info">
                        <div className="name">{item.label}</div>
                        <div className="email">{item.value}</div>
                    </div>
                </Checkbox>
            </div>
        );
    };

    const renderSelectedItem = (item: TransferRenderSelectedItemProps) => {
        const { sortableHandle } = item;
        if (!sortableHandle) {
            return null;
        }
        const DragHandle = sortableHandle(() => <IconHandle className="semi-right-item-drag-handler" />);
        return (
            <div className="components-transfer-demo-selected-item" key={item.key}>
                <DragHandle />
                <Avatar color={item.color} size="small">
                    {item.abbr}
                </Avatar>
                <div className="info">
                    <div className="name">{item.label}</div>
                    <div className="email">{item.value}</div>
                </div>
                <Button className="remove" theme="borderless" type="tertiary" size="small" icon={<IconClose />} aria-label={`移除 ${item.label}`} onClick={item.onRemove} />
            </div>
        );
    };

    const customFilter = (searchValue: string, item: TransferDataItem) => {
        return 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 },
    ];

    return (
        <Transfer
            draggable
            style={{ width: 568 }}
            dataSource={data}
            filter={customFilter}
            defaultValue={['xiakeman@example.com', 'shenyue@example.com']}
            renderSelectedItem={renderSelectedItem}
            renderSourceItem={renderSourceItem}
            inputProps={{ placeholder: '搜索姓名或邮箱' }}
            onChange={(values, items) => console.log(values, items)}
        />
    );
}

自定义渲染面板头部信息

Semi 提供 renderSourceHeader, renderSelectedHeader 参数允许用户自定义渲染左右两个面板的头部信息。
renderSourceHeader: (props: SourceHeaderProps) => ReactNode
renderSelectedHeader: (props: SelectedHeaderProps) => ReactNode
参数类型如下:

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

type SelectedHeaderProps = {
    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 项已选
暂无内容,可从左侧勾选
代码
import { Transfer, Button, type TransferProps } from '@fibrous/semi-ui-react';

export default function TransferDemo() {
    const data = Array.from({ length: 30 }, (_v, i) => {
        return {
            label: `选项名称 ${i}`,
            value: i,
            disabled: false,
            key: `key-${i}`,
        };
    });

    const renderSourceHeader: TransferProps['renderSourceHeader'] = ({ num, showButton, allChecked, onAllClick }) => {
        return (
            <div style={{ margin: '10px 0 0 10px', height: 24, display: 'flex', alignItems: 'center' }}>
                <span>共 {num} 项</span>
                {showButton && (
                    <Button theme="borderless" type="tertiary" size="small" onClick={onAllClick}>
                        {allChecked ? '取消全选' : '全选'}
                    </Button>
                )}
            </div>
        );
    };

    const renderSelectedHeader: TransferProps['renderSelectedHeader'] = ({ num, showButton, onClear }) => {
        return (
            <div style={{ margin: '10px 0 0 10px', height: 24, display: 'flex', alignItems: 'center' }}>
                <span>{num} 项已选</span>
                {showButton && (
                    <Button theme="borderless" type="tertiary" size="small" onClick={onClear}>
                        清空
                    </Button>
                )}
            </div>
        );
    };

    return <Transfer style={{ width: 568, height: 416 }} dataSource={data} renderSourceHeader={renderSourceHeader} renderSelectedHeader={renderSelectedHeader} />;
}

完全自定义渲染

Semi 提供了 renderSourcePanel、renderSelectedPanel 入参,允许你完全自定义左右侧两个面板的渲染结构
通过该功能,你可以直接复用 Transfer 内部的逻辑能力,实现高度自定义样式结构的Transfer组件 renderSourcePanel: (sourcePanelProps: SourcePanelProps) => ReactNode
SourcePanelProps包含以下参数,你可以从中获取数据来渲染出你的 Panel 结构

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

renderSelectedPanel: (selectedPanelProps: SelectedPanelProps) => ReactNode
SelectedPanelProps包含以下参数

interface SelectedPanelProps {
    length: number; // 已选项的数量
    onClear: () => void; // 点击清空时应调用的回调函数
    onRemove: (item: Item) => void; // 删除单个选项时应调用的函数
    onSortEnd: ({ oldIndex, newIndex }) => void; // 对结果重新排序时应调用的函数
    selectedData: Array<Item>; // 所有已选项集合
}
门店列表
待选门店: 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
暂无数据,请从左侧筛选
代码
import { type ReactNode } from 'react';
import { IconSearch } from '@fibrous/semi-icons-react';
import { Transfer, Input, Spin, Button, type TransferDataItem, type TransferSelectedPanelProps as SelectedPanelProps, type TransferSourcePanelProps as SourcePanelProps } from '@fibrous/semi-ui-react';

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

function renderItem(type: 'source' | 'selected', item: TransferDataItem, onItemAction: (item: TransferDataItem) => void, selectedItems?: SourcePanelProps['selectedItems']) {
    let buttonText = '删除';
    if (type === 'source') {
        buttonText = selectedItems?.has(item.key) ? '删除' : '添加';
    }
    return (
        <div className="semi-transfer-item panel-item" key={item.key}>
            <p>{item.label}</p>
            <Button theme="borderless" type="primary" onClick={() => onItemAction(item)} className="panel-item-remove" size="small">
                {buttonText}
            </Button>
        </div>
    );
}

function renderSourcePanel({ loading, noMatch, filterData, selectedItems, allChecked, onAllClick, inputValue, onSearch, onSelectOrRemove }: SourcePanelProps) {
    let content: ReactNode;
    if (loading) {
        content = <Spin />;
    } else if (noMatch) {
        content = <div className="empty sp-font">{inputValue ? '无搜索结果' : '暂无内容'}</div>;
    } else {
        content = filterData.map((item) => renderItem('source', item, onSelectOrRemove, selectedItems));
    }
    return (
        <section className="source-panel">
            <div className="panel-header sp-font">门店列表</div>
            <div className="panel-main">
                <Input aria-label="搜索门店" style={{ width: 454, margin: '12px 14px' }} prefix={<IconSearch />} onChange={onSearch} showClear />
                <div className="panel-controls sp-font">
                    <span>待选门店: {filterData.length}</span>
                    <Button onClick={onAllClick} theme="borderless" size="small">
                        {allChecked ? '取消全选' : '全选'}
                    </Button>
                </div>
                <div className="panel-list">{content}</div>
            </div>
        </section>
    );
}

function renderSelectedPanel({ selectedData, onClear, onRemove }: SelectedPanelProps) {
    let mainContent: ReactNode = selectedData.map((item) => renderItem('selected', item, onRemove));

    if (!selectedData.length) {
        mainContent = <div className="empty sp-font">暂无数据,请从左侧筛选</div>;
    }

    return (
        <section className="selected-panel">
            <div className="panel-header sp-font">
                <div>已选同步门店: {selectedData.length}</div>
                <Button theme="borderless" type="primary" onClick={onClear} size="small">
                    清空
                </Button>
            </div>
            <div className="panel-main">{mainContent}</div>
        </section>
    );
}

export default function TransferDemo() {
    return <Transfer onChange={(values) => console.log(values)} className="component-transfer-demo-custom-panel" renderSourcePanel={renderSourcePanel} renderSelectedPanel={renderSelectedPanel} dataSource={dataSource} />;
}
.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/react,其中 DragDropProvider 提供拖拽上下文,核心 hook 为 @dnd-kit/react/sortable 导出的 useSortable,使用说明如下

1. 作用:通过唯一标志 id 与当前位置 index 获取拖拽过程中必要信息
2. 核心输入参数:
    - id: 唯一标识,数字或者字符串
    - index: 该项在列表中的当前位置
    - handle: 可选,拖拽手柄的元素,不传时整个节点都可拖拽
3. 核心返回值说明:
	- ref: 关联 dom 节点,使其成为一个可拖拽的项
	- handleRef: 关联拖拽手柄的 dom 节点
	- isDragging: 该节点是否正在被拖拽

拖拽结束后,在 DragDropProvider 的 onDragEnd 中,用 event.operation.source 的 initialIndex 与 index 得到拖拽前后的位置;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

代码
import type { ReactNode } from 'react';
import { DragDropProvider, type DragEndEvent } from '@dnd-kit/react';
import { isSortable, useSortable } from '@dnd-kit/react/sortable';
import { IconSearch, IconHandle } from '@fibrous/semi-icons-react';
import { Transfer, Input, Spin, Button, type TransferDataItem, type TransferOnSortEnd as OnSortEnd, type TransferSelectedPanelProps as SelectedPanelProps, type TransferSourcePanelProps as SourcePanelProps } from '@fibrous/semi-ui-react';

type HandleRef = (element: Element | null) => void;
type RenderSortableItem = (props: { id: string | number; handleRef: HandleRef }) => ReactNode;

interface SortableListProps {
    items: Array<string | number>;
    onSortEnd: OnSortEnd;
    renderItem: RenderSortableItem;
}

function SortableList({ items, onSortEnd, renderItem }: SortableListProps) {
    const onDragEnd = (event: DragEndEvent) => {
        const { source } = event.operation;
        if (!event.canceled && isSortable(source) && source.initialIndex !== source.index) {
            onSortEnd({ oldIndex: source.initialIndex, newIndex: source.index });
        }
    };

    return (
        <DragDropProvider onDragEnd={onDragEnd}>
            <div style={{ overflow: 'auto', display: 'flex', flexDirection: 'column', rowGap: '8px' }}>
                {items.map((value, index) => (
                    <SortableItem key={value} id={value} index={index} renderItem={renderItem} />
                ))}
            </div>
        </DragDropProvider>
    );
}

interface SortableItemProps {
    id: string | number;
    index: number;
    renderItem: RenderSortableItem;
}

function SortableItem({ id, index, renderItem }: SortableItemProps) {
    // Only the handle drags; the Accessibility plugin gives it a button role and Tab stop for keyboard sorting
    const { ref, handleRef } = useSortable({ id, index });

    return <div ref={ref}>{renderItem({ id, handleRef })}</div>;
}

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

function renderItem(type: 'source' | 'selected', item: TransferDataItem, onItemAction: (item: TransferDataItem) => void, selectedItems?: SourcePanelProps['selectedItems'], handleRef?: HandleRef) {
    let buttonText = '删除';

    if (type === 'source') {
        let checked = selectedItems?.has(item.key);
        buttonText = checked ? '删除' : '添加';
    }

    return (
        <div className="semi-transfer-item panel-item" key={item.key}>
            {handleRef && (
                <span ref={handleRef} aria-label={String(item.label)} style={{ lineHeight: 0 }}>
                    <IconHandle className="pane-item-drag-handler" />
                </span>
            )}
            <div className="panel-item-main" style={{ flexGrow: 1 }}>
                <p style={{ margin: '0 12px' }}>{item.label}</p>
                <Button theme="borderless" type="primary" onClick={() => onItemAction(item)} className="panel-item-remove" size="small">
                    {buttonText}
                </Button>
            </div>
        </div>
    );
}

function renderSourcePanel({ loading, noMatch, filterData, selectedItems, allChecked, onAllClick, inputValue, onSearch, onSelectOrRemove }: SourcePanelProps) {
    let content: ReactNode;
    if (loading) {
        content = <Spin />;
    } else if (noMatch) {
        content = <div className="empty sp-font">{inputValue ? '无搜索结果' : '暂无内容'}</div>;
    } else {
        content = filterData.map((item) => renderItem('source', item, onSelectOrRemove, selectedItems));
    }
    return (
        <section className="source-panel">
            <div className="panel-header sp-font">门店列表</div>
            <div className="panel-main">
                <Input aria-label="搜索门店" style={{ width: 454, margin: '12px 14px' }} prefix={<IconSearch />} onChange={onSearch} showClear />
                <div className="panel-controls sp-font">
                    <span>待选门店: {filterData.length}</span>
                    <Button onClick={onAllClick} theme="borderless" size="small">
                        {allChecked ? '取消全选' : '全选'}
                    </Button>
                </div>
                <div className="panel-list">{content}</div>
            </div>
        </section>
    );
}

function renderSelectedPanel({ selectedData, onClear, onRemove, onSortEnd }: SelectedPanelProps) {
    const renderSelectItem: RenderSortableItem = ({ id, handleRef }) => {
        const item = selectedData.find((item) => id === item.key);
        return item ? renderItem('selected', item, onRemove, undefined, handleRef) : null;
    };

    const sortData = selectedData.map((item) => item.key);

    const mainContent = (
        <div className="panel-main" style={{ display: 'block' }}>
            <SortableList onSortEnd={onSortEnd} items={sortData} renderItem={renderSelectItem} />
        </div>
    );

    return (
        <section className="selected-panel">
            <div className="panel-header sp-font">
                <div>已选同步门店: {selectedData.length}</div>
                <Button theme="borderless" type="primary" onClick={onClear} size="small">
                    清空
                </Button>
            </div>
            {mainContent}
        </section>
    );
}

export default function TransferDemo() {
    return <Transfer defaultValue={[2, 4]} onChange={(values) => console.log(values)} className="component-transfer-demo-custom-panel" renderSourcePanel={renderSourcePanel} renderSelectedPanel={renderSelectedPanel} dataSource={dataSource} />;
}

树穿梭框

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

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

{
    multiple:true,
    disableStrictly:true,
    leafOnly:true,
    filterTreeNode:true,
    defaultExpandAll:true,
    searchRender:false,
}
总个数:12
  • Asia
  • China
  • Beijing
  • Shanghai
  • Chengdu
  • Japan
  • Osaka
  • North America
  • United States
  • Canada
  • Mexico
  • Cuba
  • 已选个数:1
    Shanghai
    代码
    import { useState } from 'react';
    import { Transfer } from '@fibrous/semi-ui-react';
    
    export default function TransferDemo() {
        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 [v, $v] = useState<Array<string | number>>(['Shanghai']);
    
        return (
            <div style={{ margin: 10, padding: 10, width: 600 }}>
                <Transfer dataSource={treeData} type="treeList" value={v} onChange={$v} />
            </div>
        );
    }

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

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

    文件总数:7 个文件(总节点数:10)
  • 文件夹 1
  • 文件 1-1
  • 文件 1-2
  • 子文件夹 1
  • 文件 1-1-1
  • 文件 1-1-2
  • 文件夹 2
  • 文件 2-1
  • 文件 2-2
  • 独立文件
  • 已选个数:0
    暂无内容,可从左侧勾选
    代码
    import { useState } from 'react';
    import { Transfer, Button, type TransferProps } from '@fibrous/semi-ui-react';
    
    export default function TransferDemo() {
        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, setValue] = useState<Array<string | number>>([]);
    
        const renderSourceHeader: TransferProps['renderSourceHeader'] = ({ num, leafOnlyNum, showButton, allChecked, onAllClick }) => {
            return (
                <div
                    style={{
                        display: 'flex',
                        justifyContent: 'space-between',
                        alignItems: 'center',
                        padding: '8px 12px',
                        borderBottom: '1px solid var(--semi-color-border)',
                    }}
                >
                    <span>
                        <strong>文件总数:</strong>
                        {leafOnlyNum !== undefined ? leafOnlyNum : num} 个文件
                        {leafOnlyNum !== undefined && <span style={{ color: 'var(--semi-color-text-2)', marginLeft: 8, fontSize: 12 }}>(总节点数:{num})</span>}
                    </span>
                    {showButton && (
                        <Button theme="borderless" type="tertiary" size="small" onClick={onAllClick}>
                            {allChecked ? '取消全选' : '全选'}
                        </Button>
                    )}
                </div>
            );
        };
    
        return <Transfer style={{ width: 600 }} dataSource={treeData} type="treeList" value={value} onChange={setValue} renderSourceHeader={renderSourceHeader} />;
    }

    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

    属性说明类型默认值
    className根元素的类名。string-
    dataSource数据源。list 类型为数据项数组,groupList 为分组数组,treeList 为带 children 的树形数据。DataSource[]
    defaultValue默认已选中的 value 数组,只在创建时读取。TypeArray<string | number>[]
    disabled是否禁用整个穿梭框,包括搜索框、选项和操作按钮。boolean-
    draggable是否开启右侧已选项的拖拽排序。排序后 onChange 的数组顺序随之改变。boolean-
    emptyContent自定义空状态的提示:left 为左侧没有数据时,right 为右侧没有已选项时,search 为搜索没有结果时。某一项为 null 时不渲染对应的空状态;未提供时显示语言包文案。EmptyContent{}
    filter搜索的筛选逻辑。为 false 时不显示搜索框;传入函数时用它替代默认匹配,返回 true 表示保留该项;默认按 label 不区分大小写匹配。treeList 想自定义搜索时需设为 true,并通过 treeProps 的 filterTreeNode 传入匹配函数。Typeboolean | ((searchValue: string, item: DataItem) => boolean)-
    inputProps传给搜索框 Input 的属性,可配置项参考 Input;value 与 onChange 由 Transfer 占用,需要外部驱动搜索时调用 ref 上的 search 方法。InputProps-
    loading左侧选项是否正在加载,加载中用 Spin 代替列表。boolean-
    onChange选中值变化时触发,拖拽排序后也会触发。参数依次为 value 数组与对应的数据项数组。Type(values: Array<string | number>, items: Array<DataItem>) => void-
    onDeselect取消勾选一项时触发,参数为该数据项。Type(item: DataItem) => void-
    onSearch搜索框输入内容变化时触发,参数为搜索值。通过 ref 的 search 方法驱动的搜索不触发。Type(searchValue: string) => void-
    onSelect勾选一项时触发,参数为该数据项。Type(item: DataItem) => void-
    pagination左侧面板的分页配置,仅对 list 与 groupList 生效。onPageChange 写在该对象内。PaginationProps-
    ref接收 TransferHandle 句柄,只暴露 search 方法,不是根元素。TypeReact.Ref<TransferHandle>-
    renderSelectedHeader自定义右侧面板头部,每次渲染时调用,参数为 SelectedHeaderProps。Type(headProps: { num: number; showButton: boolean; onClear: () => void; }) => React.ReactNode-
    renderSelectedItem自定义右侧单个已选项的渲染,每次渲染时为每个选项调用一次。参数为数据项,另含 onRemove;draggable 时还有 sortableHandle。treeList 且 showPath 时可通过 fullPath 获取完整路径。Type(item: RenderSelectedItemProps) => React.ReactNode-
    renderSelectedPanel完全自定义右侧已选面板,每次渲染时调用,参数为 SelectedPanelProps。Type(selectedPanelProps: SelectedPanelProps) => React.ReactNode-
    renderSourceHeader自定义左侧面板头部,每次渲染时调用,参数为 SourceHeaderProps。leafOnlyNum 仅 treeList 提供。Type(headProps: { num: number; showButton: boolean; allChecked: boolean; onAllClick: () => void; leafOnlyNum?: number; }) => React.ReactNode-
    renderSourceItem自定义左侧单个候选项的渲染,每次渲染时为每个选项调用一次。参数为数据项,另含 checked 与 onChange,onChange 切换该项的勾选状态。Type(item: RenderSourceItemProps) => React.ReactNode-
    renderSourcePanel完全自定义左侧候选面板,每次渲染时调用,参数为 SourcePanelProps。Type(sourcePanelProps: SourcePanelProps) => React.ReactNode-
    showPath仅 treeList 有效,控制右侧已选项是否显示从根节点到自身的选择路径。booleanfalse
    style根元素的内联样式。React.CSSProperties-
    treeProps仅 treeList 有效,传给左侧 Tree 的属性,会覆盖默认的 multiple、disableStrictly、leafOnly、filterTreeNode、defaultExpandAll 与 searchRender(为 false)。value、treeData、keyMaps、treeDataSimpleJson、onChangeWithObject、onChange 与 ref 由 Transfer 决定,传入会被忽略。TypeOmit<TreeProps, 'value' | 'ref' | 'onChange' | 'keyMaps' | 'treeData' | 'treeDataSimpleJson' | 'onChangeWithObject'>-
    type穿梭框类型,可选 list、groupList、treeList。Type"list"
    value已选中的 value 数组,传入后即为受控组件,数组顺序就是右侧的显示顺序;传入 undefined 回到非受控。TypeArray<string | number>-
    virtualize右侧已选列表虚拟化,仅在默认右侧面板且 draggable 为 false 时生效。height 为数字时按像素生效,为其他值时按面板剩余高度测量;itemSize 为每行高度。VirtualizeProps-

    Methods

    方法说明类型
    search按输入了该值来搜索,该值会被置给搜索框;不触发 onSearch。Type(value: string) => void

    未列出的原生属性(如 id、aria-* 与事件)落在根 div 上;根元素带 aria-label 或 aria-labelledby 且未指定 role 时为 role="group"。tabIndex 设置在搜索框上,包括外层 Tooltip 注入的值。搜索框的属性用 inputProps 传入,treeList 左侧 Tree 的属性用 treeProps 传入。

    下文 Item、GroupItem、TreeItem 在 @fibrous/semi-ui-react 中导出的类型名分别为 TransferDataItem、TransferGroupItem、TransferTreeItem。

    Item Interface

    属性说明类型默认值
    disabled是否禁用booleanfalse
    key必填,每个选项的唯一标识,不允许重复string | number
    label选项展示内容ReactNode
    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

    ref 接收的 TransferHandle 句柄上的方法,可以通过 ref 调用实现某些特殊交互。

    const transferRef = useRef<TransferHandle>(null);
    
    <Transfer ref={transferRef} />;
    
    transferRef.current?.search('选项名称 1');

    设计变量

    下表是组件内部的 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穿梭框字重