Transfer穿梭框

双栏穿梭选择框。

何时使用#

  • 需要在多个可选项中进行多选时。

  • 比起 Select 和 TreeSelect,穿梭框占据更大的空间,可以展示可选项的更多信息。

穿梭选择框用直观的方式在两栏中移动元素,完成选择行为。

选择一个或以上的选项后,点击对应的方向键,可以把选中的选项移动到另一栏。其中,左边一栏为 source,右边一栏为 target,API 的设计也反映了这两个概念。

代码演示

11 Source
  • content1
  • content2
  • content3
  • content4
  • content5
  • content6
  • content7
  • content8
  • content9
  • content10
  • content11
9 Target
  • content12
  • content13
  • content14
  • content15
  • content16
  • content17
  • content18
  • content19
  • content20

最基本的用法,展示了 dataSourcetargetKeys、每行的渲染函数 render 以及回调函数 onChange onSelectChange onScroll 的用法。

expand codeexpand code
import { Transfer } from 'infrad';
import type { TransferDirection } from 'infrad/es/transfer';
import React, { useState } from 'react';

interface RecordType {
  key: string;
  title: string;
  description: string;
}

const mockData: RecordType[] = Array.from({ length: 20 }).map((_, i) => ({
  key: i.toString(),
  title: `content${i + 1}`,
  description: `description of content${i + 1}`,
}));

const initialTargetKeys = mockData.filter(item => Number(item.key) > 10).map(item => item.key);

const App: React.FC = () => {
  const [targetKeys, setTargetKeys] = useState(initialTargetKeys);
  const [selectedKeys, setSelectedKeys] = useState<string[]>([]);

  const onChange = (nextTargetKeys: string[], direction: TransferDirection, moveKeys: string[]) => {
    console.log('targetKeys:', nextTargetKeys);
    console.log('direction:', direction);
    console.log('moveKeys:', moveKeys);
    setTargetKeys(nextTargetKeys);
  };

  const onSelectChange = (sourceSelectedKeys: string[], targetSelectedKeys: string[]) => {
    console.log('sourceSelectedKeys:', sourceSelectedKeys);
    console.log('targetSelectedKeys:', targetSelectedKeys);
    setSelectedKeys([...sourceSelectedKeys, ...targetSelectedKeys]);
  };

  const onScroll = (direction: TransferDirection, e: React.SyntheticEvent<HTMLUListElement>) => {
    console.log('direction:', direction);
    console.log('target:', e.target);
  };

  return (
    <Transfer
      dataSource={mockData}
      titles={['Source', 'Target']}
      targetKeys={targetKeys}
      selectedKeys={selectedKeys}
      onChange={onChange}
      onSelectChange={onSelectChange}
      onScroll={onScroll}
      render={item => item.title}
    />
  );
};

export default App;
14 Source
  • content1
  • content2
  • content4
  • content5
  • content7
  • content8
  • content10
  • content11
  • content13
  • content14
  • content16
  • content17
  • content19
  • content20
6 Target
  • content3
  • content6
  • content9
  • content12
  • content15
  • content18

通过 oneWay 将 Transfer 转为单向样式。

expand codeexpand code
import { Switch, Transfer } from 'infrad';
import type { TransferDirection } from 'infrad/es/transfer';
import React, { useState } from 'react';

interface RecordType {
  key: string;
  title: string;
  description: string;
  disabled: boolean;
}

const mockData: RecordType[] = Array.from({ length: 20 }).map((_, i) => ({
  key: i.toString(),
  title: `content${i + 1}`,
  description: `description of content${i + 1}`,
  disabled: i % 3 < 1,
}));

const oriTargetKeys = mockData.filter(item => Number(item.key) % 3 > 1).map(item => item.key);

const App: React.FC = () => {
  const [targetKeys, setTargetKeys] = useState<string[]>(oriTargetKeys);
  const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
  const [disabled, setDisabled] = useState(false);

  const handleChange = (
    newTargetKeys: string[],
    direction: TransferDirection,
    moveKeys: string[],
  ) => {
    setTargetKeys(newTargetKeys);

    console.log('targetKeys: ', newTargetKeys);
    console.log('direction: ', direction);
    console.log('moveKeys: ', moveKeys);
  };

  const handleSelectChange = (sourceSelectedKeys: string[], targetSelectedKeys: string[]) => {
    setSelectedKeys([...sourceSelectedKeys, ...targetSelectedKeys]);

    console.log('sourceSelectedKeys: ', sourceSelectedKeys);
    console.log('targetSelectedKeys: ', targetSelectedKeys);
  };

  const handleScroll = (
    direction: TransferDirection,
    e: React.SyntheticEvent<HTMLUListElement, Event>,
  ) => {
    console.log('direction:', direction);
    console.log('target:', e.target);
  };

  const handleDisable = (checked: boolean) => {
    setDisabled(checked);
  };

  return (
    <>
      <Transfer
        dataSource={mockData}
        titles={['Source', 'Target']}
        targetKeys={targetKeys}
        selectedKeys={selectedKeys}
        onChange={handleChange}
        onSelectChange={handleSelectChange}
        onScroll={handleScroll}
        render={item => item.title}
        disabled={disabled}
        oneWay
        style={{ marginBottom: 16 }}
      />
      <Switch
        unCheckedChildren="disabled"
        checkedChildren="disabled"
        checked={disabled}
        onChange={handleDisable}
      />
    </>
  );
};

export default App;

穿梭框高级用法,可配置操作文案,可定制宽高,可对底部进行自定义渲染。

expand codeexpand code
import { Button, Transfer } from 'infrad';
import type { TransferDirection, TransferListProps } from 'infrad/es/transfer';
import React, { useEffect, useState } from 'react';

interface RecordType {
  key: string;
  title: string;
  description: string;
  chosen: boolean;
}

const App: React.FC = () => {
  const [mockData, setMockData] = useState<RecordType[]>([]);
  const [targetKeys, setTargetKeys] = useState<string[]>([]);

  const getMock = () => {
    const tempTargetKeys = [];
    const tempMockData = [];
    for (let i = 0; i < 20; i++) {
      const data = {
        key: i.toString(),
        title: `content${i + 1}`,
        description: `description of content${i + 1}`,
        chosen: Math.random() * 2 > 1,
      };
      if (data.chosen) {
        tempTargetKeys.push(data.key);
      }
      tempMockData.push(data);
    }
    setMockData(tempMockData);
    setTargetKeys(tempTargetKeys);
  };

  useEffect(() => {
    getMock();
  }, []);

  const handleChange = (newTargetKeys: string[]) => {
    setTargetKeys(newTargetKeys);
  };

  const renderFooter = (
    _: TransferListProps<any>,
    {
      direction,
    }: {
      direction: TransferDirection;
    },
  ) => {
    if (direction === 'left') {
      return (
        <Button size="small" style={{ float: 'left', margin: 5 }} onClick={getMock}>
          Left button reload
        </Button>
      );
    }
    return (
      <Button size="small" style={{ float: 'right', margin: 5 }} onClick={getMock}>
        Right button reload
      </Button>
    );
  };

  return (
    <Transfer
      dataSource={mockData}
      showSearch
      listStyle={{
        width: 250,
        height: 300,
      }}
      operations={['to right', 'to left']}
      targetKeys={targetKeys}
      onChange={handleChange}
      render={item => `${item.title}-${item.description}`}
      footer={renderFooter}
    />
  );
};

export default App;
0
No Data
0
No Data

自定义渲染每一个 Transfer Item,可用于渲染复杂数据。

expand codeexpand code
import { Transfer } from 'infrad';
import type { TransferDirection } from 'infrad/es/transfer';
import React, { useEffect, useState } from 'react';

interface RecordType {
  key: string;
  title: string;
  description: string;
  chosen: boolean;
}

const App: React.FC = () => {
  const [mockData, setMockData] = useState<RecordType[]>([]);
  const [targetKeys, setTargetKeys] = useState<string[]>([]);

  const getMock = () => {
    const tempTargetKeys = [];
    const tempMockData = [];
    for (let i = 0; i < 20; i++) {
      const data = {
        key: i.toString(),
        title: `content${i + 1}`,
        description: `description of content${i + 1}`,
        chosen: Math.random() * 2 > 1,
      };
      if (data.chosen) {
        tempTargetKeys.push(data.key);
      }
      tempMockData.push(data);
    }
    setMockData(tempMockData);
    setTargetKeys(tempTargetKeys);
  };

  useEffect(() => {
    getMock();
  }, []);

  const handleChange = (
    newTargetKeys: string[],
    direction: TransferDirection,
    moveKeys: string[],
  ) => {
    console.log(newTargetKeys, direction, moveKeys);
    setTargetKeys(newTargetKeys);
  };

  const renderItem = (item: RecordType) => {
    const customLabel = (
      <span className="custom-item">
        {item.title} - {item.description}
      </span>
    );

    return {
      label: customLabel, // for displayed item
      value: item.title, // for title and filter matching
    };
  };

  return (
    <Transfer
      dataSource={mockData}
      listStyle={{
        width: 300,
        height: 300,
      }}
      targetKeys={targetKeys}
      onChange={handleChange}
      render={renderItem}
    />
  );
};

export default App;
0
No Data
0
No Data

大数据下使用分页。

expand codeexpand code
import { Switch, Transfer } from 'infrad';
import type { TransferDirection } from 'infrad/es/transfer';
import React, { useEffect, useState } from 'react';

interface RecordType {
  key: string;
  title: string;
  description: string;
  chosen: boolean;
}

const App: React.FC = () => {
  const [oneWay, setOneWay] = useState(false);
  const [mockData, setMockData] = useState<RecordType[]>([]);
  const [targetKeys, setTargetKeys] = useState<string[]>([]);

  useEffect(() => {
    const newTargetKeys = [];
    const newMockData = [];
    for (let i = 0; i < 2000; i++) {
      const data = {
        key: i.toString(),
        title: `content${i + 1}`,
        description: `description of content${i + 1}`,
        chosen: Math.random() * 2 > 1,
      };
      if (data.chosen) {
        newTargetKeys.push(data.key);
      }
      newMockData.push(data);
    }

    setTargetKeys(newTargetKeys);
    setMockData(newMockData);
  }, []);

  const onChange = (newTargetKeys: string[], direction: TransferDirection, moveKeys: string[]) => {
    console.log(newTargetKeys, direction, moveKeys);
    setTargetKeys(newTargetKeys);
  };

  return (
    <>
      <Transfer
        dataSource={mockData}
        targetKeys={targetKeys}
        onChange={onChange}
        render={item => item.title}
        oneWay={oneWay}
        pagination
      />
      <br />
      <Switch
        unCheckedChildren="one way"
        checkedChildren="one way"
        checked={oneWay}
        onChange={setOneWay}
      />
    </>
  );
};

export default App;
14
NameTagDescription
content1catdescription of content1
content2dogdescription of content2
content4catdescription of content4
content5dogdescription of content5
content7catdescription of content7
content8dogdescription of content8
content10catdescription of content10
content11dogdescription of content11
content13catdescription of content13
content14dogdescription of content14
6
Name
content3
content6
content9
content12
content15
content18

使用 Table 组件作为自定义渲染列表。

expand codeexpand code
import { Switch, Table, Tag, Transfer } from 'infrad';
import type { ColumnsType, TableRowSelection } from 'infrad/es/table/interface';
import type { TransferItem, TransferProps } from 'infrad/es/transfer';
import difference from 'lodash/difference';
import React, { useState } from 'react';

interface RecordType {
  key: string;
  title: string;
  description: string;
  disabled: boolean;
  tag: string;
}

interface DataType {
  title: string;
  tag: string;
  description: string;
}

interface TableTransferProps extends TransferProps<TransferItem> {
  dataSource: DataType[];
  leftColumns: ColumnsType<DataType>;
  rightColumns: ColumnsType<DataType>;
}

// Customize Table Transfer
const TableTransfer = ({ leftColumns, rightColumns, ...restProps }: TableTransferProps) => (
  <Transfer {...restProps}>
    {({
      direction,
      filteredItems,
      onItemSelectAll,
      onItemSelect,
      selectedKeys: listSelectedKeys,
      disabled: listDisabled,
    }) => {
      const columns = direction === 'left' ? leftColumns : rightColumns;

      const rowSelection: TableRowSelection<TransferItem> = {
        getCheckboxProps: item => ({ disabled: listDisabled || item.disabled }),
        onSelectAll(selected, selectedRows) {
          const treeSelectedKeys = selectedRows
            .filter(item => !item.disabled)
            .map(({ key }) => key);
          const diffKeys = selected
            ? difference(treeSelectedKeys, listSelectedKeys)
            : difference(listSelectedKeys, treeSelectedKeys);
          onItemSelectAll(diffKeys as string[], selected);
        },
        onSelect({ key }, selected) {
          onItemSelect(key as string, selected);
        },
        selectedRowKeys: listSelectedKeys,
      };

      return (
        <Table
          rowSelection={rowSelection}
          columns={columns}
          dataSource={filteredItems}
          size="small"
          style={{ pointerEvents: listDisabled ? 'none' : undefined }}
          onRow={({ key, disabled: itemDisabled }) => ({
            onClick: () => {
              if (itemDisabled || listDisabled) return;
              onItemSelect(key as string, !listSelectedKeys.includes(key as string));
            },
          })}
        />
      );
    }}
  </Transfer>
);

const mockTags = ['cat', 'dog', 'bird'];

const mockData: RecordType[] = Array.from({ length: 20 }).map((_, i) => ({
  key: i.toString(),
  title: `content${i + 1}`,
  description: `description of content${i + 1}`,
  disabled: i % 4 === 0,
  tag: mockTags[i % 3],
}));

const originTargetKeys = mockData.filter(item => Number(item.key) % 3 > 1).map(item => item.key);

const leftTableColumns: ColumnsType<DataType> = [
  {
    dataIndex: 'title',
    title: 'Name',
  },
  {
    dataIndex: 'tag',
    title: 'Tag',
    render: tag => <Tag>{tag}</Tag>,
  },
  {
    dataIndex: 'description',
    title: 'Description',
  },
];

const rightTableColumns: ColumnsType<Pick<DataType, 'title'>> = [
  {
    dataIndex: 'title',
    title: 'Name',
  },
];

const App: React.FC = () => {
  const [targetKeys, setTargetKeys] = useState<string[]>(originTargetKeys);
  const [disabled, setDisabled] = useState(false);
  const [showSearch, setShowSearch] = useState(false);

  const onChange = (nextTargetKeys: string[]) => {
    setTargetKeys(nextTargetKeys);
  };

  const triggerDisable = (checked: boolean) => {
    setDisabled(checked);
  };

  const triggerShowSearch = (checked: boolean) => {
    setShowSearch(checked);
  };

  return (
    <>
      <TableTransfer
        dataSource={mockData}
        targetKeys={targetKeys}
        disabled={disabled}
        showSearch={showSearch}
        onChange={onChange}
        filterOption={(inputValue, item) =>
          item.title!.indexOf(inputValue) !== -1 || item.tag.indexOf(inputValue) !== -1
        }
        leftColumns={leftTableColumns}
        rightColumns={rightTableColumns}
      />
      <Switch
        unCheckedChildren="disabled"
        checkedChildren="disabled"
        checked={disabled}
        onChange={triggerDisable}
        style={{ marginTop: 16 }}
      />
      <Switch
        unCheckedChildren="showSearch"
        checkedChildren="showSearch"
        checked={showSearch}
        onChange={triggerShowSearch}
        style={{ marginTop: 16 }}
      />
    </>
  );
};

export default App;
#components-transfer-demo-table-transfer .ant-table td {
  background: transparent;
}
5
0-0
0-1
0-1-0
0-1-1
0-3
0
No Data

使用 Tree 组件作为自定义渲染列表。

expand codeexpand code
import { Transfer, Tree } from 'infrad';
import type { TransferDirection, TransferItem } from 'infrad/es/transfer';
import type { DataNode } from 'infrad/es/tree';
import React, { useState } from 'react';

interface TreeTransferProps {
  dataSource: DataNode[];
  targetKeys: string[];
  onChange: (targetKeys: string[], direction: TransferDirection, moveKeys: string[]) => void;
}

// Customize Table Transfer
const isChecked = (selectedKeys: (string | number)[], eventKey: string | number) =>
  selectedKeys.includes(eventKey);

const generateTree = (treeNodes: DataNode[] = [], checkedKeys: string[] = []): DataNode[] =>
  treeNodes.map(({ children, ...props }) => ({
    ...props,
    disabled: checkedKeys.includes(props.key as string),
    children: generateTree(children, checkedKeys),
  }));

const TreeTransfer = ({ dataSource, targetKeys, ...restProps }: TreeTransferProps) => {
  const transferDataSource: TransferItem[] = [];
  function flatten(list: DataNode[] = []) {
    list.forEach(item => {
      transferDataSource.push(item as TransferItem);
      flatten(item.children);
    });
  }
  flatten(dataSource);

  return (
    <Transfer
      {...restProps}
      targetKeys={targetKeys}
      dataSource={transferDataSource}
      className="tree-transfer"
      render={item => item.title!}
      showSelectAll={false}
    >
      {({ direction, onItemSelect, selectedKeys }) => {
        if (direction === 'left') {
          const checkedKeys = [...selectedKeys, ...targetKeys];
          return (
            <Tree
              blockNode
              checkable
              checkStrictly
              defaultExpandAll
              checkedKeys={checkedKeys}
              treeData={generateTree(dataSource, targetKeys)}
              onCheck={(_, { node: { key } }) => {
                onItemSelect(key as string, !isChecked(checkedKeys, key));
              }}
              onSelect={(_, { node: { key } }) => {
                onItemSelect(key as string, !isChecked(checkedKeys, key));
              }}
            />
          );
        }
      }}
    </Transfer>
  );
};

const treeData: DataNode[] = [
  { key: '0-0', title: '0-0' },
  {
    key: '0-1',
    title: '0-1',
    children: [
      { key: '0-1-0', title: '0-1-0' },
      { key: '0-1-1', title: '0-1-1' },
    ],
  },
  { key: '0-2', title: '0-3' },
];

const App: React.FC = () => {
  const [targetKeys, setTargetKeys] = useState<string[]>([]);
  const onChange = (keys: string[]) => {
    setTargetKeys(keys);
  };
  return <TreeTransfer dataSource={treeData} targetKeys={targetKeys} onChange={onChange} />;
};

export default App;
0
No Data
0
No Data
0
0

使用 status 为 Transfer 添加状态,可选 error 或者 warning

expand codeexpand code
import { Space, Transfer } from 'infrad';
import React from 'react';

const App: React.FC = () => (
  <Space direction="vertical">
    <Transfer status="error" />
    <Transfer status="warning" showSearch />
  </Space>
);

export default App;
4.19.0

API#

Transfer#

参数说明类型默认值版本
dataSource数据源,其中的数据将会被渲染到左边一栏中,targetKeys 中指定的除外RecordType extends TransferItem = TransferItem[][]
disabled是否禁用booleanfalse
filterOption接收 inputValue option 两个参数,当 option 符合筛选条件时,应返回 true,反之则返回 false(inputValue, option): boolean-
footer底部渲染函数(props, { direction }) => ReactNode-direction: 4.17.0
listStyle两个穿梭框的自定义样式object|({direction: left | right}) => object-
locale各种语言{ itemUnit: string; itemsUnit: string; searchPlaceholder: string; notFoundContent: ReactNode | ReactNode[]; }{ itemUnit: , itemsUnit: , searchPlaceholder: 请输入搜索内容 }
oneWay展示为单向样式booleanfalse4.3.0
operations操作文案集合,顺序从上至下string[][>, <]
operationStyle操作栏的自定义样式CSSProperties-
pagination使用分页样式,自定义渲染列表下无效boolean | { pageSize: number, simple: boolean, showSizeChanger?: boolean, showLessItems?: boolean }false4.3.0
render每行数据渲染函数,该函数的入参为 dataSource 中的项,返回值为 ReactElement。或者返回一个普通对象,其中 label 字段为 ReactElement,value 字段为 title(record) => ReactNode-
selectAllLabels自定义顶部多选框标题的集合(ReactNode | (info: { selectedCount: number, totalCount: number }) => ReactNode)[]-
selectedKeys设置哪些项应该被选中string[][]
showSearch是否显示搜索框booleanfalse
showSelectAll是否展示全选勾选框booleantrue
status设置校验状态'error' | 'warning'-4.19.0
targetKeys显示在右侧框数据的 key 集合string[][]
titles标题集合,顺序从左至右ReactNode[]-
onChange选项在两栏之间转移时的回调函数(targetKeys, direction, moveKeys): void-
onScroll选项列表滚动时的回调函数(direction, event): void-
onSearch搜索框内容时改变时的回调函数(direction: left | right, value: string): void-
onSelectChange选中项发生改变时的回调函数(sourceSelectedKeys, targetSelectedKeys): void-

Render Props#

Transfer 支持接收 children 自定义渲染列表,并返回以下参数:

参数说明类型版本
direction渲染列表的方向left | right
disabled是否禁用列表boolean
filteredItems过滤后的数据RecordType[]
selectedKeys选中的条目string[]
onItemSelect勾选条目(key: string, selected: boolean)
onItemSelectAll勾选一组条目(keys: string[], selected: boolean)

参考示例#

<Transfer {...props}>{listProps => <YourComponent {...listProps} />}</Transfer>

注意#

按照 React 的规范,所有的组件数组必须绑定 key。在 Transfer 中,dataSource 里的数据值需要指定 key 值。对于 dataSource 默认将每列数据的 key 属性作为唯一的标识。

如果你的数据没有这个属性,务必使用 rowKey 来指定数据列的主键。

// 比如你的数据主键是 uid
return <Transfer rowKey={record => record.uid} />;

FAQ#

怎样让 Transfer 穿梭框列表支持异步数据加载#

为了保持页码同步,在勾选时可以不移除选项而以禁用代替:https://codesandbox.io/s/objective-wing-6iqbx