2020-03-31 13:47:59 +08:00

920 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { Component } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import Select from '../select';
import Cascader from '../cascader';
import Menu from '../menu';
import { func, obj, dom, KEYCODE } from '../util';
const { bindCtx } = func;
const { pickOthers } = obj;
const { getStyle } = dom;
/**
* CascaderSelect
*/
export default class CascaderSelect extends Component {
static propTypes = {
prefix: PropTypes.string,
pure: PropTypes.bool,
className: PropTypes.string,
/**
* 选择框大小
*/
size: PropTypes.oneOf(['small', 'medium', 'large']),
/**
* 选择框占位符
*/
placeholder: PropTypes.string,
/**
* 是否禁用
*/
disabled: PropTypes.bool,
/**
* 是否有下拉箭头
*/
hasArrow: PropTypes.bool,
/**
* 是否有边框
*/
hasBorder: PropTypes.bool,
/**
* 是否有清除按钮
*/
hasClear: PropTypes.bool,
/**
* 自定义内联 label
*/
label: PropTypes.node,
/**
* 是否只读,只读模式下可以展开弹层但不能选
*/
readOnly: PropTypes.bool,
/**
* 数据源,结构可参考下方说明
*/
dataSource: PropTypes.arrayOf(PropTypes.object),
/**
* (非受控)默认值
*/
defaultValue: PropTypes.oneOfType([
PropTypes.string,
PropTypes.arrayOf(PropTypes.string),
]),
/**
* (受控)当前值
*/
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.arrayOf(PropTypes.string),
]),
/**
* 选中值改变时触发的回调函数
* @param {String|Array} value 选中的值,单选时返回单个值,多选时返回数组
* @param {Object|Array} data 选中的数据,包括 value 和 label单选时返回单个值多选时返回数组父子节点选中关联时同时选中只返回父节点
* @param {Object} extra 额外参数
* @param {Array} extra.selectedPath 单选时选中的数据的路径
* @param {Boolean} extra.checked 多选时当前的操作是选中还是取消选中
* @param {Object} extra.currentData 多选时当前操作的数据
* @param {Array} extra.checkedData 多选时所有被选中的数据
* @param {Array} extra.indeterminateData 多选时半选的数据
*/
onChange: PropTypes.func,
/**
* 默认展开值,如果不设置,组件内部会根据 defaultValue/value 进行自动设置
*/
defaultExpandedValue: PropTypes.arrayOf(PropTypes.string),
/**
* 展开触发的方式
*/
expandTriggerType: PropTypes.oneOf(['click', 'hover']),
onExpand: PropTypes.func,
/**
* 是否开启虚拟滚动
*/
useVirtual: PropTypes.bool,
/**
* 是否多选
*/
multiple: PropTypes.bool,
/**
* 是否选中即发生改变, 该属性仅在单选模式下有效
*/
changeOnSelect: PropTypes.bool,
/**
* 是否只能勾选叶子项的checkbox该属性仅在多选模式下有效
*/
canOnlyCheckLeaf: PropTypes.bool,
/**
* 父子节点是否选中不关联
*/
checkStrictly: PropTypes.bool,
/**
* 每列列表样式对象
*/
listStyle: PropTypes.object,
/**
* 每列列表类名
*/
listClassName: PropTypes.string,
/**
* 选择框单选时展示结果的自定义渲染函数
* @param {Array} label 选中路径的文本数组
* @return {ReactNode} 渲染在选择框中的内容
* @default 单选时labelPath => labelPath.join(' / ')多选时labelPath => labelPath[labelPath.length - 1]
*/
displayRender: PropTypes.func,
/**
* 渲染 item 内容的方法
* @param {Object} item 渲染节点的item
* @return {ReactNode} item node
*/
itemRender: PropTypes.func,
/**
* 是否显示搜索框
*/
showSearch: PropTypes.bool,
/**
* 自定义搜索函数
* @param {String} searchValue 搜索的关键字
* @param {Array} path 节点路径
* @return {Boolean} 是否匹配
* @default 根据路径所有节点的文本值模糊匹配
*/
filter: PropTypes.func,
/**
* 搜索结果自定义渲染函数
* @param {String} searchValue 搜索的关键字
* @param {Array} path 匹配到的节点路径
* @return {ReactNode} 渲染的内容
* @default 按照节点文本 a / b / c 的模式渲染
*/
resultRender: PropTypes.func,
/**
* 搜索结果列表是否和选择框等宽
*/
resultAutoWidth: PropTypes.bool,
/**
* 无数据时显示内容
*/
notFoundContent: PropTypes.node,
/**
* 异步加载数据函数
* @param {Object} data 当前点击异步加载的数据
*/
loadData: PropTypes.func,
/**
* 自定义下拉框头部
*/
header: PropTypes.node,
/**
* 自定义下拉框底部
*/
footer: PropTypes.node,
/**
* 初始下拉框是否显示
*/
defaultVisible: PropTypes.bool,
/**
* 当前下拉框是否显示
*/
visible: PropTypes.bool,
/**
* 下拉框显示或关闭时触发事件的回调函数
* @param {Boolean} visible 是否显示
* @param {String} type 触发显示关闭的操作类型, fromTrigger 表示由trigger的点击触发 docClick 表示由document的点击触发
*/
onVisibleChange: PropTypes.func,
/**
* 下拉框自定义样式对象
*/
popupStyle: PropTypes.object,
/**
* 下拉框样式自定义类名
*/
popupClassName: PropTypes.string,
/**
* 下拉框挂载的容器节点
*/
popupContainer: PropTypes.any,
/**
* 透传到 Popup 的属性对象
*/
popupProps: PropTypes.object,
/**
* 是否跟随滚动
*/
followTrigger: PropTypes.bool,
/**
* 是否为预览态
*/
isPreview: PropTypes.bool,
/**
* 预览态模式下渲染的内容
* @param {Array<data>} value 选择值 { label: , value:}
*/
renderPreview: PropTypes.func,
};
static defaultProps = {
prefix: 'next-',
pure: false,
size: 'medium',
disabled: false,
hasArrow: true,
hasBorder: true,
hasClear: false,
dataSource: [],
defaultValue: null,
expandTriggerType: 'click',
onExpand: () => {},
useVirtual: false,
multiple: false,
changeOnSelect: false,
canOnlyCheckLeaf: false,
checkStrictly: false,
showSearch: false,
filter: (searchValue, path) => {
return path.some(item => item.label.indexOf(searchValue) > -1);
},
resultRender: (searchValue, path) => {
const parts = [];
path.forEach((item, i) => {
const others = item.label.split(searchValue);
others.forEach((other, j) => {
if (other) {
parts.push(other);
}
if (j < others.length - 1) {
parts.push(<em key={`${i}-${j}`}>{searchValue}</em>);
}
});
if (i < path.length - 1) {
parts.push(' / ');
}
});
return <span>{parts}</span>;
},
resultAutoWidth: true,
notFoundContent: 'Not Found',
defaultVisible: false,
onVisibleChange: () => {},
popupProps: {},
};
constructor(props, context) {
super(props, context);
this.state = {
value: this.normalizeValue(
'value' in props ? props.value : props.defaultValue
),
searchValue: '',
visible:
typeof props.visible === 'undefined'
? props.defaultVisible
: props.visible,
};
bindCtx(this, [
'handleVisibleChange',
'handleAfterOpen',
'handleSelect',
'handleChange',
'handleClear',
'handleRemove',
'handleSearch',
'getPopup',
'saveSelectRef',
'saveCascaderRef',
'handleKeyDown',
]);
}
componentWillReceiveProps(nextProps) {
const st = {};
if ('value' in nextProps) {
st.value = this.normalizeValue(nextProps.value);
}
if ('visible' in nextProps) {
st.visible = nextProps.visible;
}
if (Object.keys(st).length) {
this.setState(st);
}
}
normalizeValue(value) {
if (value) {
if (Array.isArray(value)) {
return value;
}
return [value];
}
return [];
}
updateCache(dataSource) {
this._v2n = {};
this._p2n = {};
const loop = (data, prefix = '0') =>
data.forEach((item, index) => {
const { value, children } = item;
const pos = `${prefix}-${index}`;
this._v2n[value] = this._p2n[pos] = { ...item, pos };
if (children && children.length) {
loop(children, pos);
}
});
loop(dataSource);
}
flatValue(value) {
const getDepth = v => {
const pos = this.getPos(v);
if (!pos) {
return 0;
}
return pos.split('-').length;
};
const newValue = value.slice(0).sort((prev, next) => {
return getDepth(prev) - getDepth(next);
});
for (let i = 0; i < newValue.length; i++) {
for (let j = 0; j < newValue.length; j++) {
if (
i !== j &&
this.isDescendantOrSelf(
this.getPos(newValue[i]),
this.getPos(newValue[j])
)
) {
newValue.splice(j, 1);
j--;
}
}
}
return newValue;
}
isDescendantOrSelf(currentPos, targetPos) {
if (!currentPos || !targetPos) {
return false;
}
const currentNums = currentPos.split('-');
const targetNums = targetPos.split('-');
return (
currentNums.length <= targetNums.length &&
currentNums.every((num, index) => {
return num === targetNums[index];
})
);
}
getValue(pos) {
return this._p2n[pos] ? this._p2n[pos].value : null;
}
getPos(value) {
return this._v2n[value] ? this._v2n[value].pos : null;
}
getData(value) {
return value.map(v => this._v2n[v]);
}
getLabelPath(data) {
const nums = data.pos.split('-');
return nums.slice(1).reduce((ret, num, index) => {
const p = nums.slice(0, index + 2).join('-');
ret.push(this._p2n[p].label);
return ret;
}, []);
}
getSignleData(value) {
if (!value.length) {
return null;
}
if (Array.isArray(value)) value = value[0];
const data = this._v2n[value];
if (!data) {
return {
value,
};
}
const labelPath = this.getLabelPath(data);
const displayRender =
this.props.displayRender || (labels => labels.join(' / '));
return {
...data,
label: displayRender(labelPath, data),
};
}
getMultipleData(value) {
if (!value.length) {
return null;
}
const { checkStrictly, canOnlyCheckLeaf, displayRender } = this.props;
let data = (checkStrictly || canOnlyCheckLeaf
? value
: this.flatValue(value)
).map(v => this._v2n[v] || { value: v });
if (displayRender) {
data = data.map(item => {
if (!item.pos) {
return item;
}
const labelPath = this.getLabelPath(item);
return {
...item,
label: displayRender(labelPath, item),
};
});
}
return data;
}
getIndeterminate(value) {
const indeterminate = [];
const positions = value.map(this.getPos.bind(this));
positions.forEach(pos => {
if (!pos) {
return false;
}
const nums = pos.split('-');
for (let i = nums.length; i > 2; i--) {
const parentPos = nums.slice(0, i - 1).join('-');
const parentValue = this.getValue(parentPos);
if (indeterminate.indexOf(parentValue) === -1) {
indeterminate.push(parentValue);
}
}
});
return indeterminate;
}
saveSelectRef(ref) {
this.select = ref;
}
saveCascaderRef(ref) {
this.cascader = ref;
}
completeValue(value) {
const newValue = [];
const flatValue = this.flatValue(value).reverse();
const ps = Object.keys(this._p2n);
for (let i = 0; i < ps.length; i++) {
for (let j = 0; j < flatValue.length; j++) {
const v = flatValue[j];
if (this.isDescendantOrSelf(this.getPos(v), ps[i])) {
newValue.push(this.getValue(ps[i]));
ps.splice(i, 1);
i--;
break;
}
}
}
return newValue;
}
isLeaf(data) {
return !(
(data.children && data.children.length) ||
(!!this.props.loadData && !data.isLeaf)
);
}
handleVisibleChange(visible, type) {
const { searchValue } = this.state;
if (!('visible' in this.props)) {
this.setState({
visible,
});
}
if (!visible && searchValue) {
this.setState({
searchValue: '',
});
}
if (['fromCascader', 'keyboard'].indexOf(type) !== -1 && !visible) {
this.select.focusInput();
}
this.props.onVisibleChange(visible, type);
}
handleKeyDown(e) {
const { onKeyDown } = this.props;
const { visible } = this.state;
if (onKeyDown) {
onKeyDown(e);
}
if (!visible) {
return;
}
switch (e.keyCode) {
case KEYCODE.UP:
case KEYCODE.DOWN:
this.cascader.setFocusValue();
e.preventDefault();
break;
default:
break;
}
}
getPopup(ref) {
this.popup = ref;
if (typeof this.props.popupProps.ref === 'function') {
this.props.popupProps.ref(ref);
}
}
handleAfterOpen() {
if (!this.popup) {
return;
}
const { prefix, popupProps } = this.props;
const dropDownNode = this.popup
.getInstance()
.overlay.getInstance()
.getContentNode();
const cascaderNode = dropDownNode.querySelector(`.${prefix}cascader`);
if (cascaderNode) {
this.cascaderHeight = getStyle(cascaderNode, 'height');
}
if (typeof popupProps.afterOpen === 'function') {
popupProps.afterOpen();
}
}
handleSelect(value, data) {
const { multiple, changeOnSelect } = this.props;
const { visible, searchValue } = this.state;
if (
!multiple &&
(!changeOnSelect || this.isLeaf(data) || !!searchValue)
) {
this.handleVisibleChange(!visible, 'fromCascader');
}
}
handleChange(value, data, extra) {
const { multiple, onChange } = this.props;
const { searchValue, value: stateValue } = this.state;
const st = {};
if (multiple && stateValue && Array.isArray(stateValue)) {
value = [...stateValue.filter(v => !this._v2n[v]), ...value];
}
if (!('value' in this.props)) {
st.value = value;
}
if (!multiple && searchValue) {
st.searchValue = '';
}
if (Object.keys(st).length) {
this.setState(st);
}
if (onChange) {
onChange(value, data, extra);
}
if (searchValue && this.select) {
this.select.handleSearchClear();
}
}
handleClear() {
// 单选时点击清空按钮
const { hasClear, multiple, treeCheckable } = this.props;
if (hasClear && (!multiple || !treeCheckable)) {
if (!('value' in this.props)) {
this.setState({
value: [],
});
}
this.props.onChange(null, null);
}
}
handleRemove(currentData) {
const { value: currentValue } = currentData;
let value;
const { multiple, checkStrictly, onChange } = this.props;
if (multiple) {
value = [...this.state.value];
value.splice(value.indexOf(currentValue), 1);
if (this.props.onChange) {
const data = this.getData(value);
const checked = false;
if (checkStrictly) {
this.props.onChange(value, data, {
checked,
currentData,
checkedData: data,
});
} else {
const checkedValue = this.completeValue(value);
const checkedData = this.getData(checkedValue);
const indeterminateValue = this.getIndeterminate(value);
const indeterminateData = this.getData(indeterminateValue);
this.props.onChange(value, data, {
checked,
currentData,
checkedData,
indeterminateData,
});
}
}
} else {
value = [];
onChange(null, null);
}
if (!('value' in this.props)) {
this.setState({
value,
});
}
}
handleSearch(searchValue) {
this.setState({
searchValue,
});
}
getPath(pos) {
const items = [];
const nums = pos.split('-');
if (nums === 2) {
items.push(this._p2n[pos]);
} else {
for (let i = 1; i < nums.length; i++) {
const p = nums.slice(0, i + 1).join('-');
items.push(this._p2n[p]);
}
}
return items;
}
filterItems() {
const {
multiple,
changeOnSelect,
canOnlyCheckLeaf,
filter,
} = this.props;
const { searchValue } = this.state;
let items = Object.keys(this._p2n).map(p => this._p2n[p]);
if ((!multiple && !changeOnSelect) || (multiple && canOnlyCheckLeaf)) {
items = items.filter(
item => !item.children || !item.children.length
);
}
return items
.map(item => this.getPath(item.pos))
.filter(path => filter(searchValue, path));
}
renderNotFound() {
const { prefix, notFoundContent } = this.props;
return (
<Menu className={`${prefix}cascader-select-not-found`}>
<Menu.Item>{notFoundContent}</Menu.Item>
</Menu>
);
}
renderCascader() {
const { dataSource } = this.props;
if (dataSource.length === 0) {
return this.renderNotFound();
}
const { searchValue } = this.state;
let filteredPaths = [];
if (searchValue) {
filteredPaths = this.filterItems();
if (filteredPaths.length === 0) {
return this.renderNotFound();
}
}
const {
multiple,
useVirtual,
changeOnSelect,
checkStrictly,
canOnlyCheckLeaf,
defaultExpandedValue,
expandTriggerType,
onExpand,
listStyle,
listClassName,
loadData,
showSearch,
resultRender,
readOnly,
itemRender,
} = this.props;
const { value } = this.state;
const props = {
dataSource,
value,
multiple,
useVirtual,
canOnlySelectLeaf: !changeOnSelect,
checkStrictly,
canOnlyCheckLeaf,
defaultExpandedValue,
expandTriggerType,
ref: this.saveCascaderRef,
onExpand,
listStyle,
listClassName,
loadData,
itemRender,
};
if (!readOnly) {
props.onChange = this.handleChange;
props.onSelect = this.handleSelect;
}
if (showSearch) {
props.searchValue = searchValue;
props.filteredPaths = filteredPaths;
props.resultRender = resultRender;
props.filteredListStyle = { height: this.cascaderHeight };
}
return <Cascader {...props} />;
}
renderPopupContent() {
const { prefix, header, footer } = this.props;
return (
<div className={`${prefix}cascader-select-dropdown`}>
{header}
{this.renderCascader()}
{footer}
</div>
);
}
renderPreview(others) {
const { prefix, multiple, className, renderPreview } = this.props;
const { value } = this.state;
const previewCls = classNames(className, `${prefix}form-preview`);
let items =
(multiple
? this.getMultipleData(value)
: this.getSignleData(value)) || [];
if (!Array.isArray(items)) {
items = [items];
}
if (typeof renderPreview === 'function') {
return (
<div {...others} className={previewCls}>
{renderPreview(items, this.props)}
</div>
);
}
return (
<p {...others} className={previewCls}>
{items.map(({ label }) => label).join(', ')}
</p>
);
}
render() {
const {
prefix,
size,
hasArrow,
hasBorder,
hasClear,
label,
readOnly,
placeholder,
dataSource,
disabled,
multiple,
className,
showSearch,
popupStyle,
popupClassName,
popupContainer,
popupProps,
followTrigger,
isPreview,
} = this.props;
const { value, searchValue, visible } = this.state;
const others = pickOthers(
Object.keys(CascaderSelect.propTypes),
this.props
);
this.updateCache(dataSource);
if (isPreview) {
return this.renderPreview(others);
}
const popupContent = this.renderPopupContent();
const props = {
prefix,
className,
size,
placeholder,
disabled,
hasArrow,
hasBorder,
hasClear,
label,
readOnly,
ref: this.saveSelectRef,
autoWidth: false,
mode: multiple ? 'multiple' : 'single',
value: multiple
? this.getMultipleData(value)
: this.getSignleData(value),
onChange: this.handleClear,
onRemove: this.handleRemove,
visible,
onVisibleChange: this.handleVisibleChange,
showSearch,
// searchValue,
onSearch: this.handleSearch,
onKeyDown: this.handleKeyDown,
popupContent,
popupStyle,
popupClassName,
popupContainer,
popupProps,
followTrigger,
};
if (showSearch) {
props.popupProps = {
...popupProps,
ref: this.getPopup,
afterOpen: this.handleAfterOpen,
};
props.autoWidth = showSearch && !!searchValue;
}
return <Select {...props} {...others} />;
}
}