输入类

穿梭框

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

代码演示

如何引入

import { Avatar, Button, ButtonGroup, Checkbox, Highlight, Input, Spin, Transfer } from '@fibrous/semi-ui-solid';

基本使用

数据项需传入 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-solid';

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

export default function TransferDemo() {
    return <Transfer style={{ width: '568px', height: '416px' }} 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-solid';

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 },
        ],
    },
];

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

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

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

使用 renderSourceItem,你可以自定义左侧每一条源数据的渲染结构。例如结合 Highlight 组件高亮搜索匹配文本
使用 renderSelectedItem 你可以自定义右侧每一条已选项的渲染结构。两者每项调用一次,参数中的 checked 是 getter,需要在返回的 JSX 中读取。

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

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 },
];

const customFilter = (searchValue: string, item: TransferDataItem) => {
    return String(item.value).includes(searchValue) || String(item.label).includes(searchValue);
};

export default function TransferDemo() {
    const [searchText, setSearchText] = createSignal('');

    const renderSourceItem = (item: TransferRenderSourceItemProps) => {
        return (
            <div class="components-transfer-demo-source-item">
                <Checkbox
                    onChange={() => {
                        item.onChange?.();
                    }}
                    checked={item.checked}
                    style={{ height: '52px', 'align-items': 'center' }}
                >
                    <Avatar color={item.color} size="small">
                        {item.abbr}
                    </Avatar>
                    <div class="info">
                        <div class="name">
                            <Highlight sourceString={String(item.label)} searchWords={[searchText()]} />
                        </div>
                        <div class="email">
                            <Highlight sourceString={String(item.value)} searchWords={[searchText()]} />
                        </div>
                    </div>
                </Checkbox>
            </div>
        );
    };

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

    return (
        <Transfer
            style={{ width: '568px' }}
            dataSource={data}
            filter={customFilter}
            defaultValue={['xiakeman@example.com', 'shenyue@example.com']}
            renderSelectedItem={renderSelectedItem}
            renderSourceItem={renderSourceItem}
            inputProps={{ placeholder: '搜索姓名或邮箱' }}
            onSearch={(text) => setSearchText(text)}
            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-solid';

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

export default function TransferDemo() {
    return <Transfer style={{ width: '568px', height: '416px' }} 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-solid';

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

export default function TransferDemo() {
    return <Transfer style={{ width: '568px', height: '416px' }} 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-solid';

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

export default function TransferDemo() {
    return (
        <Transfer
            style={{ width: '568px', height: '416px' }}
            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 { createSignal } from 'solid-js';
import { Transfer, ButtonGroup, Button } from '@fibrous/semi-ui-solid';

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

export default function TransferDemo() {
    const [currentPage, setCurrentPage] = createSignal(1);
    return (
        <div>
            <ButtonGroup style={{ 'margin-bottom': '12px' }}>
                <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: '568px', height: '416px' }}
                dataSource={data}
                pagination={{
                    pageSize: 10,
                    currentPage: currentPage(),
                    onPageChange: (page) => setCurrentPage(page),
                }}
                onChange={(values, items) => console.log(values, items)}
            />
        </div>
    );
}

拖拽 + 自定义已选项渲染

将 draggable 设为 true,开启拖拽排序功能;使用 renderSelectedItem 自定义右侧已选项的渲染。sortableHandle 接收一个包裹拖拽图标的组件,返回拖拽手柄组件,在返回的 JSX 中渲染它即可;手柄是原生 button。draggable 为 false 时不提供 sortableHandle。

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

const renderSourceItem = (item: TransferRenderSourceItemProps) => {
    return (
        <div class="components-transfer-demo-source-item">
            <Checkbox
                onChange={() => {
                    item.onChange?.();
                }}
                checked={item.checked}
                style={{ height: '52px', 'align-items': 'center' }}
            >
                <Avatar color={item.color} size="small">
                    {item.abbr}
                </Avatar>
                <div class="info">
                    <div class="name">{item.label}</div>
                    <div class="email">{item.value}</div>
                </div>
            </Checkbox>
        </div>
    );
};

const renderSelectedItem = (item: TransferRenderSelectedItemProps) => {
    // the handle exists only with `draggable`, which this demo sets
    const DragHandle = item.sortableHandle?.(() => <IconHandle class="semi-right-item-drag-handler" />);
    return (
        <div class="components-transfer-demo-selected-item">
            {DragHandle && <DragHandle />}
            <Avatar color={item.color} size="small">
                {item.abbr}
            </Avatar>
            <div class="info">
                <div class="name">{item.label}</div>
                <div class="email">{item.value}</div>
            </div>
            <Button class="remove" theme="borderless" type="tertiary" size="small" icon={<IconClose />} aria-label={`移除 ${String(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 },
];

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

自定义渲染面板头部信息

renderSourceHeader、renderSelectedHeader 的参数是实时对象,类型分别为 TransferSourceHeaderProps、TransferSelectedHeaderProps,每个面板只调用一次。读取其字段需写在 JSX 中,解构只取首次渲染时的值:

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 TransferSelectedHeaderProps, type TransferSourceHeaderProps } from '@fibrous/semi-ui-solid';

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

// the headers read their live objects in the JSX, so each renders once and follows the state
const renderSourceHeader = (header: TransferSourceHeaderProps) => {
    return (
        <div style={{ margin: '10px 0 0 10px', height: '24px', display: 'flex', 'align-items': 'center' }}>
            <span>共 {header.num} 项</span>
            {header.showButton && (
                <Button theme="borderless" type="tertiary" size="small" onClick={() => header.onAllClick()}>
                    {header.allChecked ? '取消全选' : '全选'}
                </Button>
            )}
        </div>
    );
};

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

export default function TransferDemo() {
    return <Transfer style={{ width: '568px', height: '416px' }} dataSource={data} renderSourceHeader={renderSourceHeader} renderSelectedHeader={renderSelectedHeader} />;
}

完全自定义渲染

renderSourcePanel、renderSelectedPanel 允许你完全自定义左右侧两个面板的渲染结构,并复用 Transfer 内部的筛选与选择逻辑。两个参数都是实时对象,类型分别为 TransferSourcePanelProps、TransferSelectedPanelProps,每个面板只调用一次;读取字段需写在 JSX 中。

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
}
interface SelectedPanelProps {
    length: number; // 已选项的数量
    onClear: () => void; // 点击清空时应调用的回调函数
    onRemove: (item: Item) => void; // 删除单个选项时应调用的函数,入参应为当前操作item
    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 { For, Show } from 'solid-js';
import { IconSearch } from '@fibrous/semi-icons-solid';
import { Transfer, Input, Spin, Button, type TransferSelectedPanelProps as SelectedPanelProps, type TransferSourcePanelProps as SourcePanelProps } from '@fibrous/semi-ui-solid';

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

// the panels read their live objects in the JSX: the lists follow the state without a second render call
function renderSourcePanel(panel: SourcePanelProps) {
    return (
        <section class="source-panel">
            <div class="panel-header sp-font">门店列表</div>
            <div class="panel-main">
                <Input aria-label="搜索门店" style={{ width: '454px', margin: '12px 14px' }} prefix={<IconSearch />} onChange={(value) => panel.onSearch(value)} showClear />
                <div class="panel-controls sp-font">
                    <span>待选门店: {panel.filterData.length}</span>
                    <Button onClick={() => panel.onAllClick()} theme="borderless" size="small">
                        {panel.allChecked ? '取消全选' : '全选'}
                    </Button>
                </div>
                <div class="panel-list">
                    <Show when={!panel.loading} fallback={<Spin />}>
                        <Show when={!panel.noMatch} fallback={<div class="empty sp-font">{panel.inputValue ? '无搜索结果' : '暂无内容'}</div>}>
                            <For each={panel.filterData}>
                                {(item) => (
                                    <div class="semi-transfer-item panel-item">
                                        <p>{item.label}</p>
                                        <Button theme="borderless" type="primary" onClick={() => panel.onSelectOrRemove(item)} class="panel-item-remove" size="small">
                                            {panel.selectedItems.has(item.key) ? '删除' : '添加'}
                                        </Button>
                                    </div>
                                )}
                            </For>
                        </Show>
                    </Show>
                </div>
            </div>
        </section>
    );
}

function renderSelectedPanel(panel: SelectedPanelProps) {
    return (
        <section class="selected-panel">
            <div class="panel-header sp-font">
                <div>已选同步门店: {panel.length}</div>
                <Button theme="borderless" type="primary" onClick={() => panel.onClear()} size="small">
                    清空
                </Button>
            </div>
            <div class="panel-main">
                <Show when={panel.length > 0} fallback={<div class="empty sp-font">暂无数据,请从左侧筛选</div>}>
                    <For each={panel.selectedData}>
                        {(item) => (
                            <div class="semi-transfer-item panel-item">
                                <p>{item.label}</p>
                                <Button theme="borderless" type="primary" onClick={() => panel.onRemove(item)} class="panel-item-remove" size="small">
                                    删除
                                </Button>
                            </div>
                        )}
                    </For>
                </Show>
            </div>
        </section>
    );
}

export default function TransferDemo() {
    return <Transfer onChange={(values) => console.log(values)} class="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/solid:DragDropProvider 提供拖拽上下文,@dnd-kit/solid/sortable 导出的 useSortable 接收 id 与 index,两者都以 getter 传入,列表重排后读取到的 index 随之更新。

1. 作用:通过唯一标志 id 与当前位置 index 获取拖拽过程中必要信息
2. 核心输入参数:
    - id: 唯一标识,数字或者字符串
    - index: 该项在列表中的当前位置
3. 核心返回值说明:
    - ref: 绑定可拖拽项的 dom 节点,以 ref={(node) => ref(node)} 的形式使用
    - handleRef: 绑定拖拽手柄的 dom 节点,只有手柄可以拖拽
    - isDragSource: 该节点是否正在被拖拽

拖拽结束后,在 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 { For, Show } from 'solid-js';
import { DragDropProvider, type DragDropProviderProps } from '@dnd-kit/solid';
import { isSortable, useSortable } from '@dnd-kit/solid/sortable';
import { IconSearch, IconHandle } from '@fibrous/semi-icons-solid';
import { Transfer, Input, Spin, Button, type TransferDataItem, type TransferOnSortEnd as OnSortEnd, type TransferSelectedPanelProps as SelectedPanelProps, type TransferSourcePanelProps as SourcePanelProps } from '@fibrous/semi-ui-solid';

interface SortableItemProps {
    id: string | number;
    index: number;
    items: TransferDataItem[];
    onRemove: (item: TransferDataItem) => void;
}

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

    // the rows are keyed by id, so a new selectedData array re-reads the item instead of remounting the row mid-drag
    const item = () => props.items.find((entry) => entry.key === props.id);

    return (
        <div ref={(node) => ref(node)}>
            <Show keyed when={item()}>
                {(current) => (
                    <div class="semi-transfer-item panel-item">
                        <span ref={(node) => handleRef(node)} aria-label={String(current.label)} 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' }}>{current.label}</p>
                            <Button theme="borderless" type="primary" onClick={() => props.onRemove(current)} class="panel-item-remove" size="small">
                                删除
                            </Button>
                        </div>
                    </div>
                )}
            </Show>
        </div>
    );
}

function SortableList(props: { items: TransferDataItem[]; onSortEnd: OnSortEnd; onRemove: (item: TransferDataItem) => void }) {
    const onDragEnd: DragDropProviderProps['onDragEnd'] = (event) => {
        const { source } = event.operation;
        if (!event.canceled && isSortable(source) && source.initialIndex !== source.index) {
            props.onSortEnd({ oldIndex: source.initialIndex, newIndex: source.index });
        }
    };

    return (
        <DragDropProvider onDragEnd={onDragEnd}>
            <div style={{ overflow: 'auto', display: 'flex', 'flex-direction': 'column', 'row-gap': '8px' }}>
                <For each={props.items.map((item) => item.key)}>{(id, index) => <SortableItem id={id} index={index()} items={props.items} onRemove={props.onRemove} />}</For>
            </div>
        </DragDropProvider>
    );
}

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

// the panels read their live objects in the JSX, as in the plain custom panel demo
function renderSourcePanel(panel: SourcePanelProps) {
    return (
        <section class="source-panel">
            <div class="panel-header sp-font">门店列表</div>
            <div class="panel-main">
                <Input aria-label="搜索门店" style={{ width: '454px', margin: '12px 14px' }} prefix={<IconSearch />} onChange={(value) => panel.onSearch(value)} showClear />
                <div class="panel-controls sp-font">
                    <span>待选门店: {panel.filterData.length}</span>
                    <Button onClick={() => panel.onAllClick()} theme="borderless" size="small">
                        {panel.allChecked ? '取消全选' : '全选'}
                    </Button>
                </div>
                <div class="panel-list">
                    <Show when={!panel.loading} fallback={<Spin />}>
                        <Show when={!panel.noMatch} fallback={<div class="empty sp-font">{panel.inputValue ? '无搜索结果' : '暂无内容'}</div>}>
                            <For each={panel.filterData}>
                                {(item) => (
                                    <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 theme="borderless" type="primary" onClick={() => panel.onSelectOrRemove(item)} class="panel-item-remove" size="small">
                                                {panel.selectedItems.has(item.key) ? '删除' : '添加'}
                                            </Button>
                                        </div>
                                    </div>
                                )}
                            </For>
                        </Show>
                    </Show>
                </div>
            </div>
        </section>
    );
}

function renderSelectedPanel(panel: SelectedPanelProps) {
    return (
        <section class="selected-panel">
            <div class="panel-header sp-font">
                <div>已选同步门店: {panel.length}</div>
                <Button theme="borderless" type="primary" onClick={() => panel.onClear()} size="small">
                    清空
                </Button>
            </div>
            <div class="panel-main" style={{ display: 'block' }}>
                <SortableList items={panel.selectedData} onSortEnd={panel.onSortEnd} onRemove={panel.onRemove} />
            </div>
        </section>
    );
}

export default function TransferDemo() {
    return <Transfer defaultValue={[2, 4]} onChange={(values) => console.log(values)} class="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,
}

Tree 自带的搜索框由 searchRender 为 false 隐藏,由 Transfer 自己的搜索框驱动过滤。

总个数:12
  • Asia
  • China
  • Beijing
  • Shanghai
  • Chengdu
  • Japan
  • Osaka
  • North America
  • United States
  • Canada
  • Mexico
  • Cuba
  • 已选个数:1
    Shanghai
    代码
    import { createSignal } from 'solid-js';
    import { Transfer } from '@fibrous/semi-ui-solid';
    
    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',
                },
            ],
        },
    ];
    
    export default function TransferDemo() {
        const [value, setValue] = createSignal<Array<string | number>>(['Shanghai']);
    
        return (
            <div style={{ margin: '10px', padding: '10px', width: '600px' }}>
                <Transfer dataSource={treeData} type="treeList" value={value()} onChange={(values) => setValue(values)} />
            </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 { createSignal, Show } from 'solid-js';
    import { Transfer, Button, type TransferSourceHeaderProps } from '@fibrous/semi-ui-solid';
    
    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',
        },
    ];
    
    // the header reads its live object in the JSX, so it renders once and follows the selection
    const renderSourceHeader = (header: TransferSourceHeaderProps) => {
        return (
            <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>
                    {header.leafOnlyNum !== undefined ? header.leafOnlyNum : header.num} 个文件
                    <Show when={header.leafOnlyNum !== undefined}>
                        <span style={{ color: 'var(--semi-color-text-2)', 'margin-left': '8px', 'font-size': '12px' }}>(总节点数:{header.num})</span>
                    </Show>
                </span>
                <Show when={header.showButton}>
                    <Button theme="borderless" type="tertiary" size="small" onClick={() => header.onAllClick()}>
                        {header.allChecked ? '取消全选' : '全选'}
                    </Button>
                </Show>
            </div>
        );
    };
    
    export default function TransferDemo() {
        const [value, setValue] = createSignal<Array<string | number>>([]);
    
        return <Transfer style={{ width: '600px' }} dataSource={treeData} type="treeList" value={value()} onChange={(values) => setValue(values)} 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

    属性说明类型默认值
    dataSource数据源。type 为 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接收根 div 元素,类型为 TransferElement,元素上带有 search 方法。TypeRef<TransferElement>-
    renderLabel自定义左右两侧每一项的内容与 treeList 中节点的内容,参数为数据项;未设置时显示 label。showPath 的路径仍是 label 文本。Type(item: DataItem) => JSX.Element-
    renderSelectedHeader自定义右侧面板头部,参数为实时对象(SelectedHeaderProps),只调用一次。Type(headProps: SelectedHeaderProps) => JSX.Element-
    renderSelectedItem自定义右侧单个已选项的渲染,每个选项调用一次。参数为数据项,另含 onRemove;draggable 时还有 sortableHandle。treeList 且 showPath 时可通过 fullPath 获取完整路径。Type(item: RenderSelectedItemProps) => JSX.Element-
    renderSelectedPanel完全自定义右侧已选面板,参数为实时对象(SelectedPanelProps),只调用一次。读取其字段需写在 JSX 中。Type(selectedPanelProps: SelectedPanelProps) => JSX.Element-
    renderSourceHeader自定义左侧面板头部,参数为实时对象(SourceHeaderProps),只调用一次。leafOnlyNum 仅 treeList 提供。Type(headProps: SourceHeaderProps) => JSX.Element-
    renderSourceItem自定义左侧单个候选项的渲染,每个选项调用一次。参数为数据项,另含 onChange 与 checked;checked 是 getter,需在返回的 JSX 中读取。Type(item: RenderSourceItemProps) => JSX.Element-
    renderSourcePanel完全自定义左侧候选面板,参数为实时对象(SourcePanelProps),只调用一次。读取其字段需写在 JSX 中,解构只取首次渲染时的值。Type(sourcePanelProps: SourcePanelProps) => JSX.Element-
    showPath仅 treeList 有效,控制右侧已选项是否显示从根节点到自身的选择路径。booleanfalse
    tabIndex设置在搜索框上,包括外层 Tooltip 注入的值;根元素本身不是焦点目标。number | string-
    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

    class、style、aria-* 与其他原生属性和事件落在根 div 上;根元素带 aria-label 或 aria-labelledby 且未指定 role 时为 role="group"。ref 接收根元素,搜索框的属性用 inputProps 传入,treeList 左侧 Tree 的属性用 treeProps 传入。

    Item Interface

    数据项还可以带任意自定义字段,它们会原样出现在 renderSourceItem、renderSelectedItem 的参数上。

    属性说明类型默认值
    disabled是否禁用booleanfalse
    key必填,每个选项的唯一标识,不允许重复string | number
    label选项的文本,默认搜索匹配它;自定义内容用 renderLabelstring | number
    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 拿到根元素后调用,类型为 TransferElement。

    let transfer: TransferElement | undefined;
    
    <Transfer ref={(element) => (transfer = element)} />;
    
    transfer?.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穿梭框字重