import cx from 'classnames'; import { Component, createRef } from 'react'; import Popover from '../popover'; import { I18nReceiver as Receiver, II18nLocaleCascader } from '../i18n'; import MenuContent from './components/MenuContent'; import { union, difference, getPathValue, getPathLabel, getPathToNode, } from './path-fns'; import { getNodeKey } from './node-fns'; import { ICascaderItem, CascaderValue, CascaderSearchClickHandler, CascaderChangeAction, CascaderLoadAction, ICascaderBaseProps, ICascaderChangeMeta, ICascaderLoadMeta, CascaderMenuClickHandler, CascaderMenuHoverHandler, CascaderItemSelectionState, ICascaderMultipleChangeMeta, CascaderSimplifySelectionMode, } from './types'; import SearchContent from './components/SearchContent'; import debounce from '../utils/debounce'; import TextMark from '../text-mark'; import { DisabledContext, IDisabledContext } from '../disabled'; import shallowEqual from '../utils/shallowEqual'; import { TagsTrigger } from './trigger/TagsTrigger'; import { SingleTrigger } from './trigger/SingleTrigger'; import { Forest } from './forest'; import noop from '../utils/noop'; import memorizeOne from '../utils/memorize-one'; import { ICascaderTagsProps } from './trigger/Tags'; import { simplify } from './simplify'; export { ICascaderTagsProps }; export interface IMenuCascaderCommonProps extends ICascaderBaseProps { loadOptions?: ( selectedOptions: ICascaderItem[] | null, meta: ICascaderLoadMeta ) => Promise; expandTrigger?: 'click' | 'hover'; scrollable?: boolean; /** * 滚动加载开启时,指定第一级数据是否还有更多数据 */ loadChildrenOnScroll?: boolean; searchable?: boolean; async?: boolean; asyncFilter?: ( keyword: string, limit: number ) => Promise>; filter?: (keyword: string, path: ICascaderItem[]) => boolean; highlight?: (keyword: string, path: ICascaderItem[]) => React.ReactNode; limit?: number; multipleType?: 'normal' | 'checkbox'; maxLine?: number | null; lineHeight?: number; } export interface IMenuCascaderSingleProps extends IMenuCascaderCommonProps { multiple?: false; value?: CascaderValue[]; onChange: ( value: CascaderValue[], selectedOptions: ICascaderItem[], meta: ICascaderChangeMeta ) => void; changeOnSelect?: boolean; } export interface IMenuCascaderMultipleProps extends IMenuCascaderCommonProps { multiple?: true; value?: Array; onChange: ( value: Array, selectedOptions: Array, meta: ICascaderMultipleChangeMeta ) => void; renderTags?: (props: ICascaderTagsProps) => React.ReactNode; simplifySelection?: boolean; simplifySelectionMode?: CascaderSimplifySelectionMode; } export type IMenuCascaderProps = | IMenuCascaderMultipleProps | IMenuCascaderSingleProps; interface IMenuCascaderState { options: Forest; // Value to highlight activeValue: CascaderValue[]; // 节点选中状态根据这个数据实时计算 selectedPaths: Array; visible: boolean; prevProps: IMenuCascaderProps; keyword: string; isSearching: boolean; searchResultList: Array; // 当前正在加载状态的节点,e.g. 1-11-112 loading: string[]; } function isMultiple( props: IMenuCascaderProps ): props is IMenuCascaderMultipleProps { return props.multiple; } function isSingle( props: IMenuCascaderProps ): props is IMenuCascaderSingleProps { return !props.multiple; } const FILTER_DEBOUNCE_TIME = 200; // ms const defaultFilter = (keyword: string, path: ICascaderItem[]): boolean => { return path.some(node => node.label.toLowerCase().includes(keyword.toLowerCase()) ); }; const defaultHighlight = ( keyword: string, path: ICascaderItem[] ): React.ReactNode => { return path.map((node, index) => { return ( {index !== path.length - 1 && ' / '} ); }); }; function getActiveValue(props: IMenuCascaderProps) { let activeValue: CascaderValue[] = []; if (isMultiple(props) && props.value.length > 0) { activeValue = props.value[0]; } if (isSingle(props)) { activeValue = props.value; } return activeValue; } function getSelectedPaths(props: IMenuCascaderProps, options: Forest) { const selectedPaths = isMultiple(props) ? props.value.map(x => options.getPathByValue(x)) : [options.getPathByValue(props.value)]; // Filter out nested empty arrays // This can happen if `options` and `value` are set one by one return selectedPaths.filter(p => p.length !== 0); } function toggleLoading( loading: string[], val: string, isLoading: boolean ): string[] { const contains = loading.indexOf(val) !== -1; if (isLoading && !contains) { return loading.concat(val); } if (!isLoading && contains) { return loading.filter(v => v !== val); } return loading; } function isControlled(props: IMenuCascaderProps): boolean { return ( 'visible' in props && 'onVisibleChange' in props && typeof props.onVisibleChange === 'function' ); } function getVisible( props: IMenuCascaderProps, state: IMenuCascaderState ): boolean { if (isControlled(props)) { return !!props.visible; } return state.visible; } export class MenuCascader extends Component< IMenuCascaderProps, IMenuCascaderState > { static defaultProps = { value: [], options: [], clearable: false, multiple: false, multipleType: 'checkbox', maxLine: null, lineHeight: 22, expandTrigger: 'click', scrollable: false, loadChildrenOnScroll: false, searchable: false, async: false, limit: 50, renderValue: getPathLabel, filter: defaultFilter, highlight: defaultHighlight, simplifySelectionMode: 'excludeDisabled', }; constructor(props: IMenuCascaderProps) { super(props); const options = new Forest(props.options); this.state = { options, activeValue: getActiveValue(props), visible: false, prevProps: props, selectedPaths: getSelectedPaths(props, options), keyword: '', isSearching: false, searchResultList: [], loading: [], }; } tagsTriggerRef = createRef(); static contextType = DisabledContext; context!: IDisabledContext; static getDerivedStateFromProps( props: IMenuCascaderProps, state: IMenuCascaderState ) { const { prevProps, options } = state; const newState: Partial = { prevProps: props, }; let newOptions = options; let optionsChanged = false; if (prevProps.options !== props.options) { newOptions = new Forest(props.options); newState.options = newOptions; optionsChanged = true; } if ( optionsChanged || prevProps.multiple !== props.multiple || !shallowEqual(prevProps.value, props.value) ) { newState.selectedPaths = getSelectedPaths(props, newOptions); } // Reset highlighted item when popup closes const visible = getVisible(props, state); if (!visible) { newState.activeValue = getActiveValue(props); } return newState; } private get disabled() { const { disabled = this.context.value } = this.props; return disabled; } private isControlled(): boolean { return isControlled(this.props); } private getVisible(): boolean { return getVisible(this.props, this.state); } private setVisible(visible: boolean): void { if (this.isControlled()) { this.props.onVisibleChange(visible); } else { this.setState({ visible, }); } } // 根据选中信息生成所有节点的选中状态表 O(n) private getSelectionMapImpl( selectedPaths: Array, mode: CascaderSimplifySelectionMode = 'excludeDisabled' ) { return this.state.options.reduceNodeDfs((map, node) => { const key = getNodeKey(node); const { value } = node; // 叶子节点,判断是否在选中的路径中;叶子节点不可能是 partial 状态 if (node.children.length === 0) { const selected = selectedPaths.some( path => path[path.length - 1].value === value ); map.set(key, selected ? 'on' : 'off'); } else { // 忽略禁用的选项 const children = mode === 'excludeDisabled' ? node.children.filter(n => !n.disabled) : node.children; const childrenState = children.reduce( (acc, n) => { const k = getNodeKey(n); const v = map.get(k); if (v === 'on') { acc.on += 1; } else if (v === 'off') { acc.off += 1; } return acc; }, { on: 0, off: 0 } ); const childrenCount = children.length; if (childrenState.on === childrenCount && childrenCount > 0) { map.set(key, 'on'); } else if (childrenState.off === childrenCount) { map.set(key, 'off'); } else { map.set(key, 'partial'); } } return map; }, new Map()); } private getSelectionMap = memorizeOne( (selectedPaths: Array) => { return this.getSelectionMapImpl(selectedPaths); } ); // 用来计算不同simplify模式下的selectionMap,mode用来区分全选合并路径时候disabled的options是否作为有效数据 private getSimplifySelectionMap = memorizeOne( ( selectedPaths: Array, mode: CascaderSimplifySelectionMode = 'excludeDisabled' ) => { return this.getSelectionMapImpl(selectedPaths, mode); } ); private simplify: ( options: Array, mode: CascaderSimplifySelectionMode ) => Array = (options, mode = 'excludeDisabled') => { return simplify(options, this.getSelectionMapImpl(options, mode)); }; // 搜索返回的结果列表中可能没有树状结构,这里根据 value 从当前的 options 里换取树结构中的节点 private getSearchResultList = memorizeOne( (options: Forest, resultList: Array) => { return resultList.map(path => { const values = path.map(x => x.value); return options.getPathByValue(values); }); } ); private onVisibleChange = (visible: boolean) => { const { keyword } = this.state; if (this.disabled) { return; } this.setVisible(visible); this.setState({ keyword: visible === false ? '' : keyword, }); }; private onKeywordChange = (keyword: string) => { this.setState({ keyword }, this.filterOptions); }; private filterOptions = debounce(() => { const { keyword, options } = this.state; if (!keyword) { return; } const { async, asyncFilter, filter, limit } = this.props; if (async) { this.setState({ isSearching: true }); asyncFilter(keyword, limit) .then(searchList => { this.setSearchState(searchList); }) .finally(() => { this.setState({ isSearching: false }); }); } else { const searchList = options .reducePath((acc, path) => { acc.push(path); return acc; }, []) .filter(path => filter(keyword, path)); this.setSearchState(searchList); } }, FILTER_DEBOUNCE_TIME); private setSearchState = (searchList: Array) => { const { limit } = this.props; const size = searchList.length; this.setState({ searchResultList: limit <= size ? searchList : searchList.slice(0, limit), }); }; private onMenuOptionHover: CascaderMenuHoverHandler = node => { this.onMenuOptionSelect(node, noop, 'hover'); }; private onMenuOptionClick: CascaderMenuClickHandler = (node, closePopup) => { this.onMenuOptionSelect(node, closePopup, 'click'); }; private onMenuOptionSelect = ( node: ICascaderItem, closePopup: () => void, source: 'click' | 'hover' ) => { const { loadOptions, multiple } = this.props; const { loading } = this.state; const needLoading = node.loadChildrenOnExpand && loadOptions; const selectedOptions = getPathToNode(node); const newValue = selectedOptions.map(n => n.value); const newState: Partial = { activeValue: newValue, keyword: '', }; const hasChildren = node.children && node.children.length > 0; const needClose = !node.loadChildrenOnExpand && !hasChildren && !multiple && source === 'click'; // 设置 loading 状态 const nodeKey = getNodeKey(node); if (needLoading) { newState.loading = toggleLoading(loading, nodeKey, true); } this.setState(newState as IMenuCascaderState, () => { if (needLoading) { loadOptions(selectedOptions, { action: CascaderLoadAction.LoadChildren, }).finally(() => { // 结束 loading 状态 this.setState(state => { return { loading: toggleLoading(state.loading, nodeKey, false), }; }); }); } if (isSingle(this.props)) { const { changeOnSelect = false } = this.props; const needTriggerChange = needClose || (changeOnSelect && source === 'click'); if (needTriggerChange) { this.props.onChange( selectedOptions.map(it => it.value), selectedOptions, { action: CascaderChangeAction.Change } ); } } if (needClose) { closePopup(); } }); }; /** * 复选框勾选/取消勾选才会触发,所以仅适用于多选场景 */ private toggleMenuOption = (node: ICascaderItem, checked: boolean) => { if (isMultiple(this.props)) { const { onChange } = this.props; const { options, selectedPaths: oldSelectedPaths } = this.state; // filter out paths that contain disabled node const affectedPaths = options.getPaths(node, path => path.every(node => !node.disabled) ); let selectedPaths = checked ? union(oldSelectedPaths, affectedPaths) : difference(oldSelectedPaths, affectedPaths); selectedPaths = options.sort(selectedPaths); const value = selectedPaths.map(list => list.map(n => n.value)); this.setState({ selectedPaths }, () => { onChange(value, selectedPaths, { action: CascaderChangeAction.Change, simplify: this.simplify, }); if (this.props.searchable) { // focus to search input this.tagsTriggerRef.current?.focus(); } }); } }; private onSearchOptionClick: CascaderSearchClickHandler = ( path, closePopup ) => { const activeValue = path.map(n => n.value); this.setState({ activeValue }, () => { this.onMenuOptionClick(path[path.length - 1], closePopup); }); }; private toggleSearchOption = (path: ICascaderItem[], checked: boolean) => { this.toggleMenuOption(path[path.length - 1], checked); }; private onClear = () => { this.setVisible(false); this.setState( { activeValue: [], selectedPaths: [], }, () => { if (isSingle(this.props)) { this.props.onChange([], [], { action: CascaderChangeAction.Clear }); } else { this.props.onChange([], [], { action: CascaderChangeAction.Clear, simplify: this.simplify, }); } } ); }; private scrollLoad = (parent: ICascaderItem | null) => { const { loadOptions } = this.props; // 判断是否要加载更多 const currentHasMore = parent ? parent.loadChildrenOnScroll : this.props.loadChildrenOnScroll; if (currentHasMore === false) { return Promise.resolve(); } const selectedOptions = getPathToNode(parent); return loadOptions(selectedOptions, { action: CascaderLoadAction.Scroll, }); }; private onRemove = (node: ICascaderItem) => { if (this.disabled) { return; } // 只有多选情况下才存在移除,即取消叶子节点的选中 this.toggleMenuOption(node, false); }; private renderPopoverContent = (i18n: II18nLocaleCascader) => { const { expandTrigger, scrollable, multiple, searchable, highlight, loadChildrenOnScroll, renderItemContent, getItemTooltip, renderList, multipleType, } = this.props; const { options, activeValue, keyword, isSearching, searchResultList, loading, selectedPaths, } = this.state; const visible = this.getVisible(); const selectionMap = this.getSelectionMap(selectedPaths); if (searchable && visible && keyword) { return ( ); } return ( ); }; render() { const { className, popupClassName, placeholder, searchable, clearable, renderValue, maxLine, lineHeight, } = this.props; const { selectedPaths, keyword } = this.state; const visible = this.getVisible(); const hasValue = selectedPaths.length > 0; return ( {i18n => { const triggerCommonProps = { placeholder, disabled: this.disabled, className, clearable, visible, keyword, searchable, i18n, renderValue, maxLine, lineHeight, onClear: this.onClear, onKeywordChange: this.onKeywordChange, }; return ( {isMultiple(this.props) ? ( ) : ( )} {this.renderPopoverContent(i18n)} ); }} ); } } export default MenuCascader;