123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681 |
- import React from 'react';
- import cls from 'classnames';
- import { SortableContainer, SortableElement, SortableHandle } from 'react-sortable-hoc';
- import PropTypes from 'prop-types';
- import { isEqual, noop, omit, isEmpty, isArray } from 'lodash';
- import TransferFoundation, { TransferAdapter, BasicDataItem, OnSortEndProps } from '@douyinfe/semi-foundation/transfer/foundation';
- import { _generateDataByType, _generateSelectedItems } from '@douyinfe/semi-foundation/transfer/transferUtils';
- import { cssClasses, strings } from '@douyinfe/semi-foundation/transfer/constants';
- import '@douyinfe/semi-foundation/transfer/transfer.scss';
- import BaseComponent from '../_base/baseComponent';
- import LocaleConsumer from '../locale/localeConsumer';
- import { Locale } from '../locale/interface';
- import { Checkbox } from '../checkbox/index';
- import Input, { InputProps } from '../input/index';
- import Spin from '../spin';
- import Button from '../button';
- import Tree from '../tree';
- import { IconClose, IconSearch, IconHandle } from '@douyinfe/semi-icons';
- import { Value as TreeValue, TreeProps } from '../tree/interface';
- export interface DataItem extends BasicDataItem {
- label?: React.ReactNode;
- style?: React.CSSProperties
- }
- export interface GroupItem {
- title?: string;
- children?: Array<DataItem>
- }
- export interface TreeItem extends DataItem {
- children: Array<TreeItem>
- }
- export interface RenderSourceItemProps extends DataItem {
- checked: boolean;
- onChange?: () => void
- }
- export interface RenderSelectedItemProps extends DataItem {
- onRemove?: () => void;
- sortableHandle?: typeof SortableHandle
- }
- export interface EmptyContent {
- left?: React.ReactNode;
- right?: React.ReactNode;
- search?: React.ReactNode
- }
- export type Type = 'list' | 'groupList' | 'treeList';
- export interface SourcePanelProps {
- value: Array<string | number>;
- /* Loading */
- loading: boolean;
- /* Whether there are no items that match the current search value */
- noMatch: boolean;
- /* Items that match the current search value */
- filterData: Array<DataItem>;
- /* All items */
- sourceData: Array<DataItem>;
- /* transfer props' dataSource */
- propsDataSource: DataSource;
- /* Whether to select all */
- allChecked: boolean;
- /* Number of filtered results */
- showNumber: number;
- /* Input search box value */
- inputValue: string;
- /* The function that should be called when the search box changes */
- onSearch: (searchString: string) => void;
- /* The function that should be called when all the buttons on the left are clicked */
- onAllClick: () => void;
- /* Selected item on the left */
- selectedItems: Map<string | number, DataItem>;
- /* The function that should be called when selecting or deleting a single option */
- onSelectOrRemove: (item: DataItem) => void;
- /* The function that should be called when selecting an option, */
- onSelect: (value: Array<string | number>) => void
- }
- export type OnSortEnd = ({ oldIndex, newIndex }: OnSortEndProps) => void;
- export interface SelectedPanelProps {
- /* Number of selected options */
- length: number;
- /* Collection of all selected options */
- selectedData: Array<DataItem>;
- /* Callback function that should be called when click to clear */
- onClear: () => void;
- /* The function that should be called when a single option is deleted */
- onRemove: (item: DataItem) => void;
- /* The function that should be called when reordering the results */
- onSortEnd: OnSortEnd
- }
- export interface ResolvedDataItem extends DataItem {
- _parent?: {
- title: string
- };
- _optionKey?: string | number
- }
- export interface DraggableResolvedDataItem {
- key?: string | number;
- index?: number;
- item?: ResolvedDataItem
- }
- export type DataSource = Array<DataItem> | Array<GroupItem> | Array<TreeItem>;
- interface HeaderConfig {
- totalContent: string;
- allContent: string;
- onAllClick: () => void;
- type: string;
- showButton: boolean
- }
- export interface TransferState {
- data: Array<ResolvedDataItem>;
- selectedItems: Map<number | string, ResolvedDataItem>;
- searchResult: Set<number | string>;
- inputValue: string
- }
- export interface TransferProps {
- style?: React.CSSProperties;
- className?: string;
- disabled?: boolean;
- dataSource?: DataSource;
- filter?: boolean | ((sugInput: string, item: DataItem) => boolean);
- defaultValue?: Array<string | number>;
- value?: Array<string | number>;
- inputProps?: InputProps;
- type?: Type;
- emptyContent?: EmptyContent;
- draggable?: boolean;
- treeProps?: Omit<TreeProps, 'value' | 'ref' | 'onChange'>;
- showPath?: boolean;
- loading?: boolean;
- onChange?: (values: Array<string | number>, items: Array<DataItem>) => void;
- onSelect?: (item: DataItem) => void;
- onDeselect?: (item: DataItem) => void;
- onSearch?: (sunInput: string) => void;
- renderSourceItem?: (item: RenderSourceItemProps) => React.ReactNode;
- renderSelectedItem?: (item: RenderSelectedItemProps) => React.ReactNode;
- renderSourcePanel?: (sourcePanelProps: SourcePanelProps) => React.ReactNode;
- renderSelectedPanel?: (selectedPanelProps: SelectedPanelProps) => React.ReactNode
- }
- const prefixcls = cssClasses.PREFIX;
- // SortableItem & SortableList should not be assigned inside of the render function
- const SortableItem = SortableElement((
- (props: DraggableResolvedDataItem) => (props.item.node as React.FC<DraggableResolvedDataItem>)
- ));
- const SortableList = SortableContainer(({ items }: { items: Array<ResolvedDataItem> }) => (
- <div className={`${prefixcls}-right-list`} role="list" aria-label="Selected list">
- {items.map((item, index: number) => (
- // @ts-ignore skip SortableItem type check
- <SortableItem key={item.label} index={index} item={item} />
- ))}
- </div>
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore see reasons: https://github.com/clauderic/react-sortable-hoc/issues/206
- ), { distance: 10 });
- class Transfer extends BaseComponent<TransferProps, TransferState> {
- static propTypes = {
- style: PropTypes.object,
- className: PropTypes.string,
- disabled: PropTypes.bool,
- dataSource: PropTypes.array,
- filter: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]),
- onSearch: PropTypes.func,
- inputProps: PropTypes.object,
- value: PropTypes.array,
- defaultValue: PropTypes.array,
- onChange: PropTypes.func,
- onSelect: PropTypes.func,
- onDeselect: PropTypes.func,
- renderSourceItem: PropTypes.func,
- renderSelectedItem: PropTypes.func,
- loading: PropTypes.bool,
- type: PropTypes.oneOf(['list', 'groupList', 'treeList']),
- treeProps: PropTypes.object,
- showPath: PropTypes.bool,
- emptyContent: PropTypes.shape({
- search: PropTypes.node,
- left: PropTypes.node,
- right: PropTypes.node,
- }),
- renderSourcePanel: PropTypes.func,
- renderSelectedPanel: PropTypes.func,
- draggable: PropTypes.bool,
- };
- static defaultProps = {
- type: strings.TYPE_LIST,
- dataSource: [] as DataSource,
- onSearch: noop,
- onChange: noop,
- onSelect: noop,
- onDeselect: noop,
- onClear: noop,
- defaultValue: [] as Array<string | number>,
- emptyContent: {},
- showPath: false,
- };
- _treeRef: Tree = null;
- constructor(props: TransferProps) {
- super(props);
- const { defaultValue = [], dataSource, type } = props;
- this.foundation = new TransferFoundation<TransferProps, TransferState>(this.adapter);
- this.state = {
- data: [],
- selectedItems: new Map(),
- searchResult: new Set(),
- inputValue: '',
- };
- if (Boolean(dataSource) && isArray(dataSource)) {
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore Avoid reporting errors this.state.xxx is read-only
- this.state.data = _generateDataByType(dataSource, type);
- }
- if (Boolean(defaultValue) && isArray(defaultValue)) {
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore Avoid reporting errors this.state.xxx is read-only
- this.state.selectedItems = _generateSelectedItems(defaultValue, this.state.data);
- }
- this.onSelectOrRemove = this.onSelectOrRemove.bind(this);
- this.onInputChange = this.onInputChange.bind(this);
- this.onSortEnd = this.onSortEnd.bind(this);
- }
- static getDerivedStateFromProps(props: TransferProps, state: TransferState) {
- const { value, dataSource, type, filter } = props;
- const mergedState = {} as TransferState;
- let newData = state.data;
- let newSelectedItems = state.selectedItems;
- if (Boolean(dataSource) && Array.isArray(dataSource)) {
- newData = _generateDataByType(dataSource, type);
- mergedState.data = newData;
- }
- if (Boolean(value) && Array.isArray(value)) {
- newSelectedItems = _generateSelectedItems(value, newData);
- mergedState.selectedItems = newSelectedItems;
- }
- if (!isEqual(state.data, newData)) {
- if (typeof state.inputValue === 'string' && state.inputValue !== '') {
- const filterFunc = typeof filter === 'function' ?
- (item: DataItem) => filter(state.inputValue, item) :
- (item: DataItem) => typeof item.label === 'string' && item.label.includes(state.inputValue);
- const searchData = newData.filter(filterFunc);
- const searchResult = new Set(searchData.map(item => item.key));
- mergedState.searchResult = searchResult;
- }
- }
- return isEmpty(mergedState) ? null : mergedState;
- }
- get adapter(): TransferAdapter<TransferProps, TransferState> {
- return {
- ...super.adapter,
- getSelected: () => new Map(this.state.selectedItems),
- updateSelected: selectedItems => {
- this.setState({ selectedItems });
- },
- notifyChange: (values, items) => {
- this.props.onChange(values, items);
- },
- notifySearch: input => {
- this.props.onSearch(input);
- },
- notifySelect: item => {
- this.props.onSelect(item);
- },
- notifyDeselect: item => {
- this.props.onDeselect(item);
- },
- updateInput: input => {
- this.setState({ inputValue: input });
- },
- updateSearchResult: searchResult => {
- this.setState({ searchResult });
- },
- searchTree: keyword => {
- this._treeRef && (this._treeRef as any).search(keyword); // TODO check this._treeRef.current?
- }
- };
- }
- onInputChange(value: string) {
- this.foundation.handleInputChange(value, true);
- }
- search(value: string) {
- // The search method is used to provide the user with a manually triggered search
- // Since the method is manually called by the user, setting the second parameter to false does not trigger the onSearch callback to notify the user
- this.foundation.handleInputChange(value, false);
- }
- onSelectOrRemove(item: ResolvedDataItem) {
- this.foundation.handleSelectOrRemove(item);
- }
- onSortEnd(callbackProps: OnSortEndProps) {
- this.foundation.handleSortEnd(callbackProps);
- }
- renderFilter(locale: Locale['Transfer']) {
- const { inputProps, filter, disabled } = this.props;
- if (typeof filter === 'boolean' && !filter) {
- return null;
- }
- return (
- <div role="search" aria-label="Transfer filter" className={`${prefixcls}-filter`}>
- <Input
- prefix={<IconSearch />}
- placeholder={locale.placeholder}
- showClear
- value={this.state.inputValue}
- disabled={disabled}
- onChange={this.onInputChange}
- {...inputProps}
- />
- </div>
- );
- }
- renderHeader(headerConfig: HeaderConfig) {
- const { disabled } = this.props;
- const { totalContent, allContent, onAllClick, type, showButton } = headerConfig;
- const headerCls = cls({
- [`${prefixcls}-header`]: true,
- [`${prefixcls}-right-header`]: type === 'right',
- [`${prefixcls}-left-header`]: type === 'left',
- });
- return (
- <div className={headerCls}>
- <span className={`${prefixcls}-header-total`}>{totalContent}</span>
- {showButton ? (
- <Button
- theme="borderless"
- disabled={disabled}
- type="tertiary"
- size="small"
- className={`${prefixcls}-header-all`}
- onClick={onAllClick}
- >
- {allContent}
- </Button>
- ) : null}
- </div>
- );
- }
- renderLeftItem(item: ResolvedDataItem, index: number) {
- const { renderSourceItem, disabled } = this.props;
- const { selectedItems } = this.state;
- const checked = selectedItems.has(item.key);
- if (renderSourceItem) {
- return renderSourceItem({ ...item, checked, onChange: () => this.onSelectOrRemove(item) });
- }
- const leftItemCls = cls({
- [`${prefixcls}-item`]: true,
- [`${prefixcls}-item-disabled`]: item.disabled,
- });
- return (
- <Checkbox
- key={index}
- disabled={item.disabled || disabled}
- className={leftItemCls}
- checked={checked}
- role="listitem"
- onChange={() => this.onSelectOrRemove(item)}
- x-semi-children-alias={`dataSource[${index}].label`}
- >
- {item.label}
- </Checkbox>
- );
- }
- renderLeft(locale: Locale['Transfer']) {
- const { data, selectedItems, inputValue, searchResult } = this.state;
- const { loading, type, emptyContent, renderSourcePanel, dataSource } = this.props;
- const totalToken = locale.total;
- const inSearchMode = inputValue !== '';
- const showNumber = inSearchMode ? searchResult.size : data.length;
- const filterData = inSearchMode ? data.filter(item => searchResult.has(item.key)) : data;
- // Whether to select all should be a judgment, whether the filtered data on the left is a subset of the selected items
- // For example, the filtered data on the left is 1, 3, 4;
- // The selected option is 1,2,3,4, it is true
- // The selected option is 2,3,4, then it is false
- const leftContainesNotInSelected = Boolean(filterData.find(f => !selectedItems.has(f.key)));
- const totalText = totalToken.replace('${total}', `${showNumber}`);
- const headerConfig: HeaderConfig = {
- totalContent: totalText,
- allContent: leftContainesNotInSelected ? locale.selectAll : locale.clearSelectAll,
- onAllClick: () => this.foundation.handleAll(leftContainesNotInSelected),
- type: 'left',
- showButton: type !== strings.TYPE_TREE_TO_LIST,
- };
- const inputCom = this.renderFilter(locale);
- const headerCom = this.renderHeader(headerConfig);
- const noMatch = inSearchMode && searchResult.size === 0;
- const emptySearch = emptyContent.search ? emptyContent.search : locale.emptySearch;
- const emptyLeft = emptyContent.left ? emptyContent.left : locale.emptyLeft;
- const emptyDataCom = this.renderEmpty('left', emptyLeft);
- const emptySearchCom = this.renderEmpty('left', emptySearch);
- const loadingCom = <Spin />;
- let content: React.ReactNode = null;
- switch (true) {
- case loading:
- content = loadingCom;
- break;
- case noMatch:
- content = emptySearchCom;
- break;
- case data.length === 0:
- content = emptyDataCom;
- break;
- case type === strings.TYPE_TREE_TO_LIST:
- content = (
- <>
- {headerCom}
- {this.renderLeftTree()}
- </>
- );
- break;
- case !noMatch && (type === strings.TYPE_LIST || type === strings.TYPE_GROUP_LIST):
- content = (
- <>
- {headerCom}
- {this.renderLeftList(filterData)}
- </>
- );
- break;
- default:
- content = null;
- break;
- }
- const { values } = this.foundation.getValuesAndItemsFromMap(selectedItems);
- const renderProps: SourcePanelProps = {
- loading,
- noMatch,
- filterData,
- sourceData: data,
- propsDataSource: dataSource,
- allChecked: !leftContainesNotInSelected,
- showNumber,
- inputValue,
- selectedItems,
- value: values,
- onSelect: this.foundation.handleSelect.bind(this.foundation),
- onAllClick: () => this.foundation.handleAll(leftContainesNotInSelected),
- onSearch: this.onInputChange,
- onSelectOrRemove: (item: ResolvedDataItem) => this.onSelectOrRemove(item),
- };
- if (renderSourcePanel) {
- return renderSourcePanel(renderProps);
- }
- return (
- <section className={`${prefixcls}-left`}>
- {inputCom}
- {content}
- </section>
- );
- }
- renderGroupTitle(group: GroupItem, index: number) {
- const groupCls = cls(`${prefixcls }-group-title`);
- return (
- <div className={groupCls} key={`title-${index}`}>
- {group.title}
- </div>
- );
- }
- renderLeftTree() {
- const { selectedItems } = this.state;
- const { disabled, dataSource, treeProps } = this.props;
- const { values } = this.foundation.getValuesAndItemsFromMap(selectedItems);
- const onChange = (value: TreeValue) => {
- this.foundation.handleSelect(value);
- };
- const restTreeProps = omit(treeProps, ['value', 'ref', 'onChange']);
- return (
- <Tree
- disabled={disabled}
- treeData={dataSource as any}
- multiple
- disableStrictly
- value={values}
- defaultExpandAll
- leafOnly
- ref={tree => this._treeRef = tree}
- filterTreeNode
- searchRender={false}
- searchStyle={{ padding: 0 }}
- style={{ flex: 1, overflow: 'overlay' }}
- onChange={onChange}
- {...restTreeProps}
- />
- );
- }
- renderLeftList(visibileItems: Array<ResolvedDataItem>) {
- const content = [] as Array<React.ReactNode>;
- const groupStatus = new Map();
- visibileItems.forEach((item, index) => {
- const parentGroup = item._parent;
- const optionContent = this.renderLeftItem(item, index);
- if (parentGroup && groupStatus.has(parentGroup.title)) {
- // group content already insert
- content.push(optionContent);
- } else if (parentGroup) {
- const groupContent = this.renderGroupTitle(parentGroup, index);
- groupStatus.set(parentGroup.title, true);
- content.push(groupContent);
- content.push(optionContent);
- } else {
- content.push(optionContent);
- }
- });
- return <div className={`${prefixcls}-left-list`} role="list" aria-label="Option list">{content}</div>;
- }
- renderRightItem(item: ResolvedDataItem): React.ReactNode {
- const { renderSelectedItem, draggable, type, showPath } = this.props;
- const onRemove = () => this.foundation.handleSelectOrRemove(item);
- const rightItemCls = cls({
- [`${prefixcls}-item`]: true,
- [`${prefixcls}-right-item`]: true,
- [`${prefixcls}-right-item-draggable`]: draggable
- });
- const shouldShowPath = type === strings.TYPE_TREE_TO_LIST && showPath === true;
- const label = shouldShowPath ? this.foundation._generatePath(item) : item.label;
- if (renderSelectedItem) {
- return renderSelectedItem({ ...item, onRemove, sortableHandle: SortableHandle });
- }
- const DragHandle = SortableHandle(() => (
- <IconHandle role="button" aria-label="Drag and sort" className={`${prefixcls}-right-item-drag-handler`} />
- ));
- return (
- // https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex
- <div role="listitem" className={rightItemCls} key={item.key}>
- {draggable ? <DragHandle /> : null}
- <div className={`${prefixcls}-right-item-text`}>{label}</div>
- <IconClose
- onClick={onRemove}
- aria-disabled={item.disabled}
- className={cls(`${prefixcls}-item-close-icon`, {
- [`${prefixcls}-item-close-icon-disabled`]: item.disabled
- })}
- />
- </div>
- );
- }
- renderEmpty(type: string, emptyText: React.ReactNode) {
- const emptyCls = cls({
- [`${prefixcls}-empty`]: true,
- [`${prefixcls}-right-empty`]: type === 'right',
- [`${prefixcls}-left-empty`]: type === 'left',
- });
- return <div aria-label="empty" className={emptyCls}>{emptyText}</div>;
- }
- renderRightSortableList(selectedData: Array<ResolvedDataItem>) {
- const sortableListItems = selectedData.map(item => ({
- ...item,
- node: this.renderRightItem(item)
- }));
- // helperClass:add styles to the helper(item being dragged) https://github.com/clauderic/react-sortable-hoc/issues/87
- // @ts-ignore skip SortableItem type check
- const sortList = <SortableList useDragHandle helperClass={`${prefixcls}-right-item-drag-item-move`} onSortEnd={this.onSortEnd} items={sortableListItems} />;
- return sortList;
- }
- renderRight(locale: Locale['Transfer']) {
- const { selectedItems } = this.state;
- const { emptyContent, renderSelectedPanel, draggable } = this.props;
- const selectedData = [...selectedItems.values()];
- // when custom render panel
- const renderProps: SelectedPanelProps = {
- length: selectedData.length,
- selectedData,
- onClear: () => this.foundation.handleClear(),
- onRemove: item => this.foundation.handleSelectOrRemove(item),
- onSortEnd: props => this.onSortEnd(props)
- };
- if (renderSelectedPanel) {
- return renderSelectedPanel(renderProps);
- }
- const selectedToken = locale.selected;
- const selectedText = selectedToken.replace('${total}', `${selectedData.length}`);
- const headerConfig = {
- totalContent: selectedText,
- allContent: locale.clear,
- onAllClick: () => this.foundation.handleClear(),
- type: 'right',
- showButton: Boolean(selectedData.length),
- };
- const headerCom = this.renderHeader(headerConfig);
- const emptyCom = this.renderEmpty('right', emptyContent.right ? emptyContent.right : locale.emptyRight);
- const panelCls = `${prefixcls}-right`;
- let content = null;
- switch (true) {
- // when empty
- case !selectedData.length:
- content = emptyCom;
- break;
- case selectedData.length && !draggable:
- const list = (
- <div className={`${prefixcls}-right-list`} role="list" aria-label="Selected list">
- {selectedData.map(item => this.renderRightItem({ ...item }))}
- </div>
- );
- content = list;
- break;
- case selectedData.length && draggable:
- content = this.renderRightSortableList(selectedData);
- break;
- default:
- break;
- }
- return (
- <section className={panelCls}>
- {headerCom}
- {content}
- </section>
- );
- }
- render() {
- const { className, style, disabled, renderSelectedPanel, renderSourcePanel } = this.props;
- const transferCls = cls(prefixcls, className, {
- [`${prefixcls}-disabled`]: disabled,
- [`${prefixcls}-custom-panel`]: renderSelectedPanel && renderSourcePanel,
- });
- return (
- <LocaleConsumer componentName="Transfer">
- {(locale: Locale['Transfer']) => (
- <div className={transferCls} style={style}>
- {this.renderLeft(locale)}
- {this.renderRight(locale)}
- </div>
- )}
- </LocaleConsumer>
- );
- }
- }
- export default Transfer;
|