/* eslint-disable max-len */ /* eslint-disable no-empty */ /* eslint-disable react/no-did-update-set-state */ import * as React from "react" import RcTreeSelect, { SHOW_ALL, SHOW_PARENT, SHOW_CHILD, TreeNode, } from "rc-tree-select" import PropTypes from "prop-types" import shallowEqual from "shallowequal" import classNames from "classnames" import toArray from "rc-util/lib/Children/toArray" import omit from "../_util/omit" import { ConfigContext } from "../config-provider" import Checkbox from "../checkbox" import Icon from "../icon" import Popover, { IPopoverProps } from "../popover" import Portal from "../portal" import Spinner from "../spinner" import "./style" const prefix = "adui-tree-select" const sameValueSplitter = "__@@__" const noop = () => {} const strategies = { "show-all": SHOW_ALL, "show-child": SHOW_CHILD, "show-parent": SHOW_PARENT, } /** * a1 是否包含 a2 */ const isArrayIncludes = ( a1: React.ReactText[], a2: React.ReactText[] ): boolean => { if (!Array.isArray(a1) || !Array.isArray(a2)) { return false } return a2.every((o) => a1.includes(o)) } export type TreeNodeValue = React.ReactText[] | null export interface ITreeNode { value: string label: string key: string disabled: boolean } export interface DataNode { [key: string]: any value?: React.ReactText title?: React.ReactNode label?: React.ReactNode key?: React.ReactText disabled?: boolean disableCheckbox?: boolean checkable?: boolean popover?: React.ReactNode popoverProps?: IPopoverProps children?: DataNode[] } export interface BaseOptionType { disabled?: boolean checkable?: boolean disableCheckbox?: boolean children?: BaseOptionType[] [name: string]: any } export interface DefaultOptionType extends BaseOptionType { value?: React.ReactText title?: React.ReactNode label?: React.ReactNode key?: React.Key children?: DefaultOptionType[] } export type TreeData = DataNode[] | undefined export interface ITreeSelectProps { [key: string]: any autoClearSearchValue?: boolean bottomContent?: React.ReactNode className?: string defaultValue?: TreeNodeValue disabled?: boolean filterTreeNode?: | boolean | ((inputValue: string, treeNode: DefaultOptionType) => boolean) filterCaseSensitive?: boolean getPopupContainer?: null | ((node: HTMLElement) => HTMLElement) intent?: "normal" | "primary" | "success" | "warning" | "danger" maxTagCount?: null | number multiple?: boolean onChange?: (value: TreeNodeValue, titleList: React.ReactNode[]) => void onDropdownVisibleChange?: (visible: boolean) => void onSearchEnter?: (e: KeyboardEvent) => void onSearch?: (value: string, nodes: DataNode[]) => void resultRender?: null | ((values: ITreeNode[]) => JSX.Element) resultVisible?: boolean sameValueEnabled?: boolean selectAll?: boolean showCheckedStrategy?: "show-all" | "show-child" | "show-parent" topContent?: React.ReactNode treeData?: TreeData value?: TreeNodeValue } type GetFuncType = T extends boolean ? never : T type FilterFn = GetFuncType export interface ITreeSelectState { hash: string bottomContentPortalTarget?: HTMLDivElement topContentPortalTarget?: HTMLDivElement value?: TreeNodeValue } export interface ITreeNodeProps { [key: string]: any className?: string checkable?: boolean disabled?: boolean disableCheckbox?: boolean title?: string | React.ReactNode key?: string eventKey?: string isLeaf?: boolean checked?: boolean expanded?: boolean loading?: boolean selected?: boolean selectable?: boolean children?: React.ReactNode } /** * 树形选择 */ class TreeSelect extends React.Component { public static propTypes = { /** * 值被选择后,是否需要清空搜索框 */ autoClearSearchValue: PropTypes.bool, /** * 下拉框底部显示的自定义元素 */ bottomContent: PropTypes.node, /** * 附加类名 */ className: PropTypes.string, /** * 默认选中的 key */ defaultValue: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), /** * 是否禁用 */ disabled: PropTypes.bool, /** * 自定义树节点过滤函数 */ filterTreeNode: PropTypes.any, /** * 是否开启大小写敏感搜索,默认为 false */ filterCaseSensitive: PropTypes.bool, /** * 指定弹出层的父级,默认为 document.body */ getPopupContainer: PropTypes.func, /** * 类型 */ intent: PropTypes.oneOf([ "normal", "primary", "success", "warning", "danger", ]), /** * 最多显示多少个 tag,如果超过了则以收起的形式出现 */ maxTagCount: PropTypes.number, /** * 是否多选模式 */ multiple: PropTypes.bool, /** * 值变化时的回调,参数: value */ onChange: PropTypes.func, /** * 下拉 visible 变化时的回调,参数: boolean */ onDropdownVisibleChange: PropTypes.func, /** * 搜索时的回调,参数: value */ onSearch: PropTypes.func, /** * 搜索回车时的回调,参数: e */ onSearchEnter: PropTypes.func, /** * 设置输入框的默认内容 */ placeholder: PropTypes.string, /** * 设置自定义的结果渲染 */ resultRender: PropTypes.func, /** * 输入框内的标签是否显示,此 Prop 用于想要将结果区展示在别的地方的需求 */ resultVisible: PropTypes.bool, /** * 定制树形选择器的icon */ rightIcon: PropTypes.string, /** * 是否允许相同值,使用情况:如同一个人可能存在于两个组织架构中,如果使用此 Prop,请同时使用 resultVisible={false} */ sameValueEnabled: PropTypes.bool, /** * 是否开启全选功能 */ selectAll: PropTypes.bool, /** * 定义选中项回填的方式: * 1. show-all:显示包括父节点的所有选中节点; * 2. show-parent:只显示父节点; * 3. show-child:只显示子节点。默认值:show-parent */ showCheckedStrategy: PropTypes.oneOf([ "show-all", "show-parent", "show-child", ]), /** * 下拉框顶部显示的自定义元素 */ topContent: PropTypes.node, /** * treeNodes 数据,格式参照 interface DataNode */ treeData: PropTypes.array, /** * 外部控制:选中的 key */ value: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), } public static defaultProps: ITreeSelectProps = { autoClearSearchValue: false, bottomContent: undefined, className: undefined, defaultValue: null, disabled: false, filterTreeNode: undefined, filterCaseSensitive: false, getPopupContainer: null, intent: "normal", maxTagCount: null, multiple: true, onChange: noop, onDropdownVisibleChange: noop, onSearch: noop, onSearchEnter: noop, placeholder: "请选择", resultRender: null, resultVisible: true, rightIcon: "list", sameValueEnabled: false, selectAll: false, showCheckedStrategy: "show-parent", topContent: undefined, treeData: undefined, value: null, } public static getDerivedStateFromProps = ({ value }: ITreeSelectProps) => { if (value !== null) { return { value, } } return null } public static TreeNode: typeof TreeNode = TreeNode public select: typeof RcTreeSelect public portal: React.ReactNode public wrapper: HTMLDivElement public treeData: DataNode[] public treeValueAll: any[] public treeValueFlatten: any[] = [] constructor(props: ITreeSelectProps) { super(props) const { value, defaultValue } = props const valueState = value !== null ? value : defaultValue this.state = { hash: Math.random().toString(36).substring(3, 8), value: valueState, } } public shouldComponentUpdate = ( nextProps: ITreeSelectProps, nextState: ITreeSelectState ) => !shallowEqual(this.props, nextProps) || !shallowEqual(this.state, nextState) public saveSelect = (node: React.ReactNode) => { this.select = node } public saveWrapper = (node: HTMLDivElement) => { this.wrapper = node } public renderSwitcherIcon = ({ isLeaf, loading }: ITreeNodeProps) => { if (loading) { return } if (isLeaf) { return null } return ( ) } public handleVisibleChange = (visible: boolean) => { setTimeout(() => { this.forceUpdate(() => { const { bottomContent, topContent } = this.props const { hash, bottomContentPortalTarget, topContentPortalTarget } = this.state /** * 下拉框显示 && 传入了顶部自定义元素 && 首次添加 */ if ( visible && ((topContent && !topContentPortalTarget) || (bottomContent && !bottomContentPortalTarget)) ) { const dropdown = document.querySelector( `.${prefix}-dropdown_${hash}` ) as HTMLDivElement if (dropdown) { if (topContent && !topContentPortalTarget) { const el = document.createElement("div") el.onmousedown = (e) => { e.preventDefault() e.stopImmediatePropagation() } dropdown.insertBefore(el, dropdown.children[0]) this.setState({ topContentPortalTarget: el, }) } if (bottomContent && !bottomContentPortalTarget) { const el = document.createElement("div") el.onmousedown = (e) => { e.preventDefault() e.stopImmediatePropagation() } dropdown.appendChild(el) this.setState({ bottomContentPortalTarget: el, }) } } } }) }, 0) } public handleChange = ( value: TreeNodeValue, titleList: React.ReactNode[] ) => { let valueParam = [] as React.ReactText[] const { disabled, multiple, onChange, value: valueProp, sameValueEnabled, } = this.props const { value: valueState } = this.state if (!disabled) { if (sameValueEnabled) { value?.forEach((v) => { const splited = `${v}`.split(sameValueSplitter) const val = splited[splited.length - 1] const { length } = value.filter((o) => `${o}`.endsWith(`${sameValueSplitter}${val}`) ) if ( length === this.treeValueFlatten.filter((o) => `${o}`.endsWith(`${sameValueSplitter}${val}`) ).length ) { // 表示全包含了,则保留 valueParam.push(val) } else if (!valueState?.includes(val)) { valueParam.push(val) } }) } else if (value) { valueParam = value } if (multiple) { valueParam = [...new Set(valueParam)] } if (valueProp === null) { this.setState({ value: valueParam }) } if (onChange) { onChange(valueParam, titleList) } } } public getFilteredTreeData = (searchValue: string) => { const { filterTreeNode } = this.props if (!searchValue || filterTreeNode === false) { return this.treeData } let filterOptionFunc: FilterFn if (typeof filterTreeNode === "function") { filterOptionFunc = filterTreeNode } else { filterOptionFunc = this.filterTreeNode } const dig = ( list: DefaultOptionType[], keepAll: boolean = false ): DataNode[] => { // @ts-ignore return list .map((dataNode) => { const { children } = dataNode const match = keepAll || (filterOptionFunc && filterOptionFunc(searchValue, dataNode)) const childList = dig(children || [], match) if (match || childList.length) { return { ...dataNode, children: childList, } } return null }) .filter((node) => node) } return dig(this.treeData) } public handleSearch = (val: string) => { const { onSearch } = this.props if (onSearch) { onSearch(val, this.getFilteredTreeData(val)) setTimeout(() => { this.forceUpdate() }, 0) } } public filterTreeNode = (input: string, treeNode: any) => { const { filterCaseSensitive } = this.props const { key, title, value, dataTitle } = treeNode if (!key || !value) { return false } if (!filterCaseSensitive) { return String(dataTitle || title) .toLocaleLowerCase() .includes(input.toLocaleLowerCase()) } return String(dataTitle || title).includes(input) } public getMaxTagCount = () => { const { maxTagCount } = this.props const { value } = this.state if (!maxTagCount || !value || value.length <= maxTagCount) { return null } return 0 } public handleSelectAll = () => { const { value: valueProp, onChange } = this.props const { value } = this.state if (isArrayIncludes(value || [], this.treeValueAll)) { /** * 已全选 */ if (valueProp === null) { this.setState({ value: [] }) } if (onChange) { onChange([], []) } } else { /** * 清空 */ if (valueProp === null) { this.setState({ value: this.treeValueAll }) } if (onChange) { onChange(this.treeValueAll, []) } } } public getMaxTagPlaceholder = (nodes: ITreeNode[]) => { if (!this.select) { return null } const { onChange, resultRender, value, disabled: disabledProp } = this.props if (resultRender) { return resultRender(nodes) } const { length } = nodes const placeholderText = ( <> {nodes[0]?.label} 等 {length} 个 ) return ( e.stopPropagation()} > {nodes.map(({ key, disabled, label }, i) => { return (
{label} {!disabled && ( { const vals = nodes.map((o) => o.value) vals.splice(i, 1) if (value === null) { this.setState({ value: vals }) } const labels = nodes.map((o) => o.label) labels.splice(i, 1) if (onChange) { onChange(vals, labels) } }} /> )}
) })} } popupClassName={`${prefix}-maxPopover`} >
e.stopPropagation()} > {placeholderText} { if (!disabledProp) { if (value === null) { this.setState({ value: [] }) } if (onChange) { onChange([], []) } } }} />
) } public convertChildrenToData = (nodes: any): DataNode[] => toArray(nodes) .map((node: React.ReactElement) => { if (!React.isValidElement(node) || !node.type) { return null } const { key, props: { children, value, popover, popoverProps, title, ...restProps }, } = node as React.ReactElement /** * 如果有 popover 则在这里包装一下 title */ const data = { key, value, dataTitle: title, title: popover ? ( <>
{title}
) : ( title ), ...restProps, } const childData = this.convertChildrenToData(children) if (childData.length) { data.children = childData } return data }) .filter((data) => data) public convertTreeData = ( dataNodeParam: TreeData, options: { selectAll?: boolean } = {} ): TreeData => { const { sameValueEnabled } = this.props if (!dataNodeParam) { return undefined } const dataNode = [...dataNodeParam] const { value: valueState } = this.state const isAllSelected = isArrayIncludes(valueState || [], this.treeValueAll) const indeterminate = !isAllSelected && !!valueState?.length if (dataNode[0]?.key === `${prefix}-tree-treenode-all`) { return dataNode } if (sameValueEnabled && !dataNode[0]?.dataTitle) { this.treeValueFlatten = [] } const { selectAll } = options const process = (node: DataNode[], parentValue?: React.ReactText) => { node.forEach((o) => { const { children, key, title, dataChildren, dataTitle, value, popover, popoverProps = {}, } = o if (dataTitle && dataChildren) { return } /** * dataTitle 用于有 popover 的情况下的搜索 */ if (title && !dataTitle) { o.dataTitle = title } /** * 加入 dataChildren 是为了支持 loadData 的使用 */ if (children?.length) { o.dataChildren = 1 } if (key === undefined) { o.key = value } else if (value === undefined) { o.value = key } if (sameValueEnabled) { const newVal = `${ parentValue ? `${parentValue}${sameValueSplitter}` : "" }${value}` o.key = newVal o.value = newVal if (!this.treeValueFlatten.includes(newVal)) { this.treeValueFlatten.push(newVal) } } if (popover) { o.title = ( <>
{title}
) } if (children) { process(children, o.value) } }) } process(dataNode) if (selectAll) { dataNode.unshift({ title: (
全选
), className: `${prefix}-tree-treenode-all`, key: `${prefix}-tree-treenode-all`, value: `${prefix}-tree-treenode-all`, checkable: false, disableCheckbox: true, }) } return dataNode } listenInputChange = (e: any) => { const { onSearchEnter } = this.props const { target: { value }, key, } = e if (key === "Backspace" && value === "") { e.stopPropagation() } setTimeout(() => { if (key === "Enter" && onSearchEnter) { onSearchEnter(e) } }, 0) } public componentDidMount = () => { const input = this.wrapper?.querySelector("input") const { maxTagCount, placeholder, resultVisible } = this.props const { value } = this.state if (input) { // 禁止 tree-select 键盘回删会影响 value input.addEventListener("keydown", this.listenInputChange, false) } if (maxTagCount !== null && value) { this.forceUpdate() } if (!resultVisible && this.wrapper) { if (input) { input.placeholder = placeholder } } } public componentDidUpdate = () => { const { resultVisible, placeholder } = this.props /** * resultVisible 为 false 时, selector 高度不会改变。 * 根据我的经验 Popup 触发重新定位的时机是: * 1. window resize * 2. resize observer 了 selector * 因此这里手动触发 resize */ if (!resultVisible) { setTimeout(() => { window.dispatchEvent(new Event("resize")) const input = this.wrapper?.querySelector("input") if (input) { input.placeholder = placeholder } }, 0) } } public render() { const { autoClearSearchValue, bottomContent, children, className, filterTreeNode, getPopupContainer, intent, maxTagCount, multiple, onDropdownVisibleChange, placeholder, rightIcon, resultRender, resultVisible, sameValueEnabled, selectAll, showCheckedStrategy, topContent, treeData, ...otherProps } = this.props const restProps: any = omit(otherProps, [ "filterCaseSensitive", "onChange", "onSearch", "onSearchEnter", "defaultValue", "value", ]) const { hash, bottomContentPortalTarget, topContentPortalTarget, value } = this.state const classSet = classNames(className, `${prefix}-${intent}`, { [`${prefix}_resultHidden`]: !resultVisible, }) let checkbox: any = multiple if (multiple) { checkbox = (
) } if (value !== null) { restProps.value = value } if (sameValueEnabled) { const trueValue: TreeNodeValue = [] if (restProps.value) { restProps.value.forEach((o: any) => { this.treeValueFlatten.forEach((p) => { const splited = p.split(sameValueSplitter) if (splited[splited.length - 1] === o) { trueValue.push(p) } }) }) } restProps.value = trueValue } if (maxTagCount !== null) { const count = this.getMaxTagCount() if (count !== null) { restProps.maxTagCount = count } } if (resultRender) { restProps.maxTagCount = 0 } const treeDataFinal = this.convertTreeData(treeData || this.convertChildrenToData(children), { selectAll, }) || [] this.treeData = treeDataFinal if (showCheckedStrategy === "show-parent") { this.treeValueAll = treeDataFinal .map(({ value: val, disabled, disableCheckbox, checkable }) => { if ( val !== undefined && !disabled && !disableCheckbox && checkable !== false ) { return val } return undefined }) .filter((o) => Boolean(o) || o === 0) } else { const treeValueAll: any[] = [] const getAllValue = (data: DataNode[]) => { data.forEach( ({ value: val, disabled, disableCheckbox, checkable, children: child, }) => { if ( val !== undefined && !disabled && !disableCheckbox && checkable !== false ) { if (showCheckedStrategy === "show-all" || !child) { treeValueAll.push(val) } else { getAllValue(child) } } } ) } getAllValue(treeDataFinal) this.treeValueAll = treeValueAll.filter((o) => Boolean(o) || o === 0) } // 是否是多层级 const multiLevel = treeDataFinal.some((o) => o.children?.length) return ( {({ getPopupContainer: getPopupContainerContext }) => (
{topContent && topContentPortalTarget && ( {topContent} )} {bottomContent && bottomContentPortalTarget && ( {bottomContent} )} } maxTagPlaceholder={this.getMaxTagPlaceholder} notFoundContent={ <> 无匹配项 } onChange={this.handleChange} onSearch={this.handleSearch} placeholder={placeholder} prefixCls={prefix} ref={this.saveSelect} removeIcon={} showCheckedStrategy={ showCheckedStrategy && strategies[showCheckedStrategy] } showSearch={multiple} switcherIcon={this.renderSwitcherIcon} transitionName="slide-up" treeCheckable={checkbox} treeNodeFilterProp="title" listItemHeight={32} listHeight={280} treeData={treeDataFinal} onDropdownVisibleChange={(visible: boolean) => { /** * 20210323 visible false 时输入框会被情况,但没有调用 onSearch */ if (!visible) { this.handleSearch("") } this.handleVisibleChange(visible) if (onDropdownVisibleChange) { onDropdownVisibleChange(visible) } }} {...restProps} /> {!!rightIcon && multiple && ( )}
)}
) } } export default TreeSelect