UNPKG

@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
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;