代码演示
如何引入
import { Avatar, Button, ButtonGroup, Checkbox, Highlight, Input, Spin, Transfer } from '@fibrous/semi-ui-solid';
基本使用
数据项需传入 value、label、key
代码
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>
暂不支持多层嵌套
代码
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 中读取。
代码
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);
}
}
禁用
代码
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,开启拖拽排序功能。
代码
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: 每页显示的条数,默认为 10currentPage: 当前页码(受控模式)defaultCurrentPage: 默认当前页码(非受控模式)onPageChange: 页码变化时的回调
代码
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 可以受控地设置当前页码。
代码
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。
代码
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; // 点击清空按钮后应调用的函数
};
使用示例如下
代码
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>; // 所有已选项集合
}
海底捞门店 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
代码
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 表示拖拽已取消。
海底捞门店 0
海底捞门店 1
海底捞门店 2
海底捞门店 3
海底捞门店 4
海底捞门店 5
海底捞门店 6
海底捞门店 7
海底捞门店 8
海底捞门店 9
海底捞门店 10
海底捞门店 11
海底捞门店 12
海底捞门店 13
海底捞门店 14
海底捞门店 15
海底捞门店 16
海底捞门店 17
海底捞门店 18
海底捞门店 19
海底捞门店 20
海底捞门店 21
海底捞门店 22
海底捞门店 23
海底捞门店 24
海底捞门店 25
海底捞门店 26
海底捞门店 27
海底捞门店 28
海底捞门店 29
海底捞门店 30
海底捞门店 31
海底捞门店 32
海底捞门店 33
海底捞门店 34
海底捞门店 35
海底捞门店 36
海底捞门店 37
海底捞门店 38
海底捞门店 39
海底捞门店 40
海底捞门店 41
海底捞门店 42
海底捞门店 43
海底捞门店 44
海底捞门店 45
海底捞门店 46
海底捞门店 47
海底捞门店 48
海底捞门店 49
海底捞门店 50
海底捞门店 51
海底捞门店 52
海底捞门店 53
海底捞门店 54
海底捞门店 55
海底捞门店 56
海底捞门店 57
海底捞门店 58
海底捞门店 59
海底捞门店 60
海底捞门店 61
海底捞门店 62
海底捞门店 63
海底捞门店 64
海底捞门店 65
海底捞门店 66
海底捞门店 67
海底捞门店 68
海底捞门店 69
海底捞门店 70
海底捞门店 71
海底捞门店 72
海底捞门店 73
海底捞门店 74
海底捞门店 75
海底捞门店 76
海底捞门店 77
海底捞门店 78
海底捞门店 79
海底捞门店 80
海底捞门店 81
海底捞门店 82
海底捞门店 83
海底捞门店 84
海底捞门店 85
海底捞门店 86
海底捞门店 87
海底捞门店 88
海底捞门店 89
海底捞门店 90
海底捞门店 91
海底捞门店 92
海底捞门店 93
海底捞门店 94
海底捞门店 95
海底捞门店 96
海底捞门店 97
海底捞门店 98
海底捞门店 99
海底捞门店 2
海底捞门店 4
代码
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 自己的搜索框驱动过滤。
代码
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 字段,表示叶子节点的数量。这在文件选择等场景中非常有用,可以在头部只显示文件数量而不是包含文件夹的总数。
代码
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时,自动加rolegroup - 左侧选项列表添加
rolelist,选项为listitem;groupList的每个分组是外层列表的一个listitem,其中分组标题命名嵌套的list,选项在嵌套列表里 - 右侧选中列表添加
rolelist,选中项添加rolelistitem;自定义渲染的左右条目外会包一层rolelistitem的元素
键盘交互
- 默认过滤不区分大小写
- 禁用时左右两个列表是 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 有效,控制右侧已选项是否显示从根节点到自身的选择路径。 | boolean | false |
| 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 | 是否禁用 | boolean | false |
| key | 必填,每个选项的唯一标识,不允许重复 | string | number | |
| label | 选项的文本,默认搜索匹配它;自定义内容用 renderLabel | string | 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 | 默认当前页码(非受控模式) | number | 1 |
| pageSize | 每页显示条数 | number | 10 |
| onPageChange | 页码变化时的回调 | (currentPage: number) => void |
Methods
绑定在根元素上的方法,通过 ref 拿到根元素后调用,类型为 TransferElement。
let transfer: TransferElement | undefined;
<Transfer ref={(element) => (transfer = element)} />;
transfer?.search('选项名称 1');
设计变量
| Sass 变量 | 值 | 用法 |
|---|---|---|
| $transition_duration_transfer_item-bg | var(--semi-transition_duration-none) | 穿梭框条目-背景色-动画持续时间 |
| $transition_function_transfer_item-bg | var(--semi-transition_function-easeIn) | 穿梭框条目-背景色-过渡曲线 |
| $transition_delay_transfer_item-bg | var(--semi-transition_delay-none) | 穿梭框条目-背景色-延迟时间 |
| $color-transfer-bg | var(--semi-color-bg-1) | 穿梭框背景颜色 - 默认 |
| $color-transfer-border | var(--semi-color-border) | 穿梭框描边颜色 - 默认 |
| $color-transfer_header-text | var(--semi-color-text-2) | 穿梭框 header 文字颜色 |
| $color-transfer_empty-text | var(--semi-color-text-2) | 穿梭框空状态文字颜色 |
| $color-transfer_group_title-text | var(--semi-color-text-2) | 穿梭框分组标题文字颜色 |
| $color-transfer_disabled-text | var(--semi-color-disabled-text) | 穿梭框禁用文本文字颜色 |
| $color-transfer_close_icon-icon | var(--semi-color-text-2) | 穿梭框关闭按钮颜色 |
| $color-transfer_item-text | var(--semi-color-text-1) | 穿梭框常规文字颜色 |
| $color-transfer_selected_item-text | var(--semi-color-text-0) | 穿梭框选中条目文字颜色 |
| $color-transfer_item-bg-hover | var(--semi-color-fill-0) | 穿梭框条目背景色 - 悬浮 |
| $color-transfer_item-bg-active | var(--semi-color-fill-1) | 穿梭框条目背景色 - 按下 |
| $width-transfer-border | 1px | 穿梭框描边宽度 |
| $width-transfer-minWidth | 402px | 穿梭框整体最小宽度 |
| $height-transfer | 400px | 穿梭框高度 |
| $height-transfer_header | 24px | 穿梭框 header 高度 |
| $height-transfer_item-minHeight | 36px | 穿梭框条目最小高度 |
| $width-transfer_item_close-icon | $width-icon-small | 穿梭框选中条目删除按钮宽度 |
| $width-transfer_left | 50% | 穿梭框左侧面板宽度 |
| $width-transfer_right | 50% | 穿梭框左侧面板宽度 |
| $height-transfer_left_empty | 36px | 穿梭框空状态高度 |
| $width-transfer_left-border | $border-thickness-control | 穿梭框双侧面板分割线宽度 |
| $height-transfer_right_header | 32px | 穿梭框 header 高度 |
| $height-transfer_right_empty | 100% | 穿梭框右侧空状态宽度 |
| $width-transfer_empty | 100% | 穿梭框整体空状态宽度 |
| $height-transfer_group_title | 28px | 穿梭框分组标题高度 |
| $spacing-transfer_header-marginTop | 12px | 穿梭框 header 顶部外边距 |
| $spacing-transfer_header-marginRight | 12px | 穿梭框 header 右侧外边距 |
| $spacing-transfer_header-marginBottom | 8px | 穿梭框 header 底部外边距 |
| $spacing-transfer_header-marginLeft | 12px | 穿梭框 header 左侧外边距 |
| $spacing-transfer_header_all-marginLeft | $spacing-base | 穿梭框全选按钮左侧外边距 |
| $spacing-transfer_item-paddingTop | $spacing-tight | 穿梭框条目顶部内边距 |
| $spacing-transfer_item-paddingRight | 9px | 穿梭框条目右侧内边距 |
| $spacing-transfer_item-paddingBottom | $spacing-tight | 穿梭框条目底部内边距 |
| $spacing-transfer_item-paddingLeft | $spacing-base-tight | 穿梭框条目左侧内边距 |
| $spacing-transfer_right_header-marginTop | $spacing-base-tight | 穿梭框右侧面板 header 顶部外边距 |
| $spacing-transfer_right_header-marginBottom | 0 | 穿梭框右侧面板 header 底部外边距 |
| $spacing-transfer_right_item_drag_handler-marginRight | 4px | 穿梭框拖动图标右侧外边距 |
| $spacing-transfer_filter-marginTop | 12px | 穿梭框搜索顶部外边距 |
| $spacing-transfer_filter-marginRight | 12px | 穿梭框搜索右侧外边距 |
| $spacing-transfer_filter-marginBottom | 0 | 穿梭框搜索底部外边距 |
| $spacing-transfer_filter-marginLeft | 12px | 穿梭框搜索左侧外边距 |
| $spacing-transfer_group_title-paddingLeft | $spacing-base-tight | 穿梭框分组标题左侧内边距 |
| $radius-transfer | var(--semi-border-radius-medium) | 穿梭框圆角 |
| $font-transfer_header_all-fontWeight | 600 | 穿梭框字重 |