@douyinfe/semi-ui
Version:
A modern, comprehensive, flexible design system and UI library. Connect DesignOps & DevOps. Quickly build beautiful React apps. Maintained by Douyin-fe team.
611 lines • 20.5 kB
JavaScript
import _isArray from "lodash/isArray";
import _isEmpty from "lodash/isEmpty";
import _omit from "lodash/omit";
import _noop from "lodash/noop";
import _isEqual from "lodash/isEqual";
var __rest = this && this.__rest || function (s, e) {
var t = {};
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p];
if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]];
}
return t;
};
import React from 'react';
import cls from 'classnames';
import PropTypes from 'prop-types';
import TransferFoundation from '@douyinfe/semi-foundation/lib/es/transfer/foundation';
import { _generateDataByType, _generateSelectedItems } from '@douyinfe/semi-foundation/lib/es/transfer/transferUtils';
import { cssClasses, strings } from '@douyinfe/semi-foundation/lib/es/transfer/constants';
import '@douyinfe/semi-foundation/lib/es/transfer/transfer.css';
import BaseComponent from '../_base/baseComponent';
import LocaleConsumer from '../locale/localeConsumer';
import { Checkbox } from '../checkbox/index';
import Input from '../input/index';
import Spin from '../spin';
import Button from '../button';
import Tree from '../tree';
import { IconClose, IconSearch, IconHandle } from '@douyinfe/semi-icons';
import { Sortable } from '../_sortable';
import { verticalListSortingStrategy } from '@dnd-kit/sortable';
const prefixCls = cssClasses.PREFIX;
class Transfer extends BaseComponent {
constructor(props) {
super(props);
this._treeRef = null;
this.renderRightItem = (item, sortableHandle) => {
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(Object.assign(Object.assign({}, item), {
onRemove,
sortableHandle
}));
}
const DragHandle = sortableHandle && sortableHandle(() => (/*#__PURE__*/React.createElement(IconHandle, {
role: "button",
"aria-label": "Drag and sort",
className: `${prefixCls}-right-item-drag-handler`
})));
return (
/*#__PURE__*/
// https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex
React.createElement("div", {
role: "listitem",
className: rightItemCls,
key: item.key
}, draggable && sortableHandle ? /*#__PURE__*/React.createElement(DragHandle, null) : null, /*#__PURE__*/React.createElement("div", {
className: `${prefixCls}-right-item-text`
}, label), /*#__PURE__*/React.createElement(IconClose, {
onClick: onRemove,
"aria-disabled": item.disabled,
className: cls(`${prefixCls}-item-close-icon`, {
[`${prefixCls}-item-close-icon-disabled`]: item.disabled
})
}))
);
};
this.renderSortItem = props => {
const {
id,
sortableHandle
} = props;
const {
selectedItems
} = this.state;
const selectedData = [...selectedItems.values()];
const item = selectedData.find(item => item.key === id);
return this.renderRightItem(item, sortableHandle);
};
const {
defaultValue = [],
dataSource,
type
} = props;
this.foundation = new TransferFoundation(this.adapter);
this.state = {
data: [],
selectedItems: new Map(),
searchResult: new Set(),
inputValue: ''
};
if (Boolean(dataSource) && _isArray(dataSource)) {
// @ts-ignore Avoid reporting errors this.state.xxx is read-only
this.state.data = _generateDataByType(dataSource, type);
}
if (Boolean(defaultValue) && _isArray(defaultValue)) {
// @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, state) {
const {
value,
dataSource,
type,
filter
} = props;
const mergedState = {};
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 => filter(state.inputValue, item) : item => 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() {
return Object.assign(Object.assign({}, 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.search(keyword); // TODO check this._treeRef.current?
}
});
}
onInputChange(value) {
this.foundation.handleInputChange(value, true);
}
search(value) {
// 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) {
this.foundation.handleSelectOrRemove(item);
}
onSortEnd(callbackProps) {
this.foundation.handleSortEnd(callbackProps);
}
renderFilter(locale) {
const {
inputProps,
filter,
disabled
} = this.props;
if (typeof filter === 'boolean' && !filter) {
return null;
}
return /*#__PURE__*/React.createElement("div", {
role: "search",
"aria-label": "Transfer filter",
className: `${prefixCls}-filter`
}, /*#__PURE__*/React.createElement(Input, Object.assign({
prefix: /*#__PURE__*/React.createElement(IconSearch, null),
placeholder: locale.placeholder,
showClear: true,
value: this.state.inputValue,
disabled: disabled,
onChange: this.onInputChange
}, inputProps)));
}
renderHeader(headerConfig) {
const {
disabled,
renderSourceHeader,
renderSelectedHeader
} = 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'
});
if (type === 'left' && typeof renderSourceHeader === 'function') {
const {
num,
showButton,
allChecked,
onAllClick
} = headerConfig;
return renderSourceHeader({
num,
showButton,
allChecked,
onAllClick
});
}
if (type === 'right' && typeof renderSelectedHeader === 'function') {
const {
num,
showButton,
onAllClick: onClear
} = headerConfig;
return renderSelectedHeader({
num,
showButton,
onClear
});
}
return /*#__PURE__*/React.createElement("div", {
className: headerCls
}, /*#__PURE__*/React.createElement("span", {
className: `${prefixCls}-header-total`
}, totalContent), showButton ? (/*#__PURE__*/React.createElement(Button, {
theme: "borderless",
disabled: disabled,
type: "tertiary",
size: "small",
className: `${prefixCls}-header-all`,
onClick: onAllClick
}, allContent)) : null);
}
renderLeftItem(item, index) {
const {
renderSourceItem,
disabled
} = this.props;
const {
selectedItems
} = this.state;
const checked = selectedItems.has(item.key);
if (renderSourceItem) {
return renderSourceItem(Object.assign(Object.assign({}, item), {
checked,
onChange: () => this.onSelectOrRemove(item)
}));
}
const leftItemCls = cls({
[`${prefixCls}-item`]: true,
[`${prefixCls}-item-disabled`]: item.disabled
});
return /*#__PURE__*/React.createElement(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);
}
renderLeft(locale) {
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
let filterDataAllDisabled = true;
const leftContainsNotInSelected = Boolean(filterData.find(f => {
if (f.disabled) {
return false;
} else {
if (filterDataAllDisabled) {
filterDataAllDisabled = false;
}
return !selectedItems.has(f.key);
}
}));
const totalText = totalToken.replace('${total}', `${showNumber}`);
const headerConfig = {
totalContent: totalText,
allContent: leftContainsNotInSelected ? locale.selectAll : locale.clearSelectAll,
onAllClick: () => this.foundation.handleAll(leftContainsNotInSelected),
type: 'left',
showButton: type !== strings.TYPE_TREE_TO_LIST && !filterDataAllDisabled,
num: showNumber,
allChecked: !leftContainsNotInSelected
};
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 = /*#__PURE__*/React.createElement(Spin, null);
let content = 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 = /*#__PURE__*/React.createElement(React.Fragment, null, headerCom, this.renderLeftTree());
break;
case !noMatch && (type === strings.TYPE_LIST || type === strings.TYPE_GROUP_LIST):
content = /*#__PURE__*/React.createElement(React.Fragment, null, headerCom, this.renderLeftList(filterData));
break;
default:
content = null;
break;
}
const {
values
} = this.foundation.getValuesAndItemsFromMap(selectedItems);
const renderProps = {
loading,
noMatch,
filterData,
sourceData: data,
propsDataSource: dataSource,
allChecked: !leftContainsNotInSelected,
showNumber,
inputValue,
selectedItems,
value: values,
onSelect: this.foundation.handleSelect.bind(this.foundation),
onAllClick: () => this.foundation.handleAll(leftContainsNotInSelected),
onSearch: this.onInputChange,
onSelectOrRemove: item => this.onSelectOrRemove(item)
};
if (renderSourcePanel) {
return renderSourcePanel(renderProps);
}
return /*#__PURE__*/React.createElement("section", {
className: `${prefixCls}-left`
}, inputCom, content);
}
renderGroupTitle(group, index) {
const groupCls = cls(`${prefixCls}-group-title`);
return /*#__PURE__*/React.createElement("div", {
className: groupCls,
key: `title-${index}`
}, group.title);
}
renderLeftTree() {
const {
selectedItems
} = this.state;
const {
disabled,
dataSource,
treeProps
} = this.props;
const {
values
} = this.foundation.getValuesAndItemsFromMap(selectedItems);
const onChange = value => {
this.foundation.handleSelect(value);
};
const restTreeProps = _omit(treeProps, ['value', 'ref', 'onChange']);
return /*#__PURE__*/React.createElement(Tree, Object.assign({
disabled: disabled,
treeData: dataSource,
multiple: true,
disableStrictly: true,
value: values,
defaultExpandAll: true,
leafOnly: true,
ref: tree => this._treeRef = tree,
filterTreeNode: true,
searchRender: false,
searchStyle: {
padding: 0
},
style: {
flex: 1,
overflow: 'overlay'
},
onChange: onChange
}, restTreeProps));
}
renderLeftList(visibileItems) {
const content = [];
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 /*#__PURE__*/React.createElement("div", {
className: `${prefixCls}-left-list`,
role: "list",
"aria-label": "Option list"
}, content);
}
renderEmpty(type, emptyText) {
const emptyCls = cls({
[`${prefixCls}-empty`]: true,
[`${prefixCls}-right-empty`]: type === 'right',
[`${prefixCls}-left-empty`]: type === 'left'
});
return /*#__PURE__*/React.createElement("div", {
"aria-label": "empty",
className: emptyCls
}, emptyText);
}
renderRightSortableList(selectedData) {
const sortItems = selectedData.map(item => item.key);
const sortList = /*#__PURE__*/React.createElement(Sortable, {
strategy: verticalListSortingStrategy,
onSortEnd: this.onSortEnd,
items: sortItems,
renderItem: this.renderSortItem,
prefix: `${prefixCls}-right-item`,
dragOverlayCls: `${prefixCls}-right-item-drag-item-move`
});
return sortList;
}
renderRight(locale) {
const {
selectedItems
} = this.state;
const {
emptyContent,
renderSelectedPanel,
draggable
} = this.props;
const selectedData = [...selectedItems.values()];
// when custom render panel
const renderProps = {
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 hasValidSelected = selectedData.findIndex(item => !item.disabled) !== -1;
const headerConfig = {
totalContent: selectedText,
allContent: locale.clear,
onAllClick: () => this.foundation.handleClear(),
type: 'right',
showButton: Boolean(selectedData.length) && hasValidSelected,
num: 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 = /*#__PURE__*/React.createElement("div", {
className: `${prefixCls}-right-list`,
role: "list",
"aria-label": "Selected list"
}, selectedData.map(item => this.renderRightItem(Object.assign({}, item))));
content = list;
break;
case selectedData.length && draggable:
content = this.renderRightSortableList(selectedData);
break;
default:
break;
}
return /*#__PURE__*/React.createElement("section", {
className: panelCls
}, headerCom, content);
}
render() {
const _a = this.props,
{
className,
style,
disabled,
renderSelectedPanel,
renderSourcePanel
} = _a,
rest = __rest(_a, ["className", "style", "disabled", "renderSelectedPanel", "renderSourcePanel"]);
const transferCls = cls(prefixCls, className, {
[`${prefixCls}-disabled`]: disabled,
[`${prefixCls}-custom-panel`]: renderSelectedPanel && renderSourcePanel
});
return /*#__PURE__*/React.createElement(LocaleConsumer, {
componentName: "Transfer"
}, locale => (/*#__PURE__*/React.createElement("div", Object.assign({
className: transferCls,
style: style
}, this.getDataAttr(rest)), this.renderLeft(locale), this.renderRight(locale))));
}
}
Transfer.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
};
Transfer.defaultProps = {
type: strings.TYPE_LIST,
dataSource: [],
onSearch: _noop,
onChange: _noop,
onSelect: _noop,
onDeselect: _noop,
onClear: _noop,
defaultValue: [],
emptyContent: {},
showPath: false
};
export default Transfer;