diff --git a/package.json b/package.json index 9a1da9e8..de422abc 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ }, "dependencies": { "array-tree-filter": "^2.1.0", + "rc-select": "^11.1.1", "rc-trigger": "^4.0.0", "rc-util": "^5.0.1", "warning": "^4.0.1" diff --git a/src/OptionList.tsx b/src/OptionList.tsx new file mode 100644 index 00000000..6891775c --- /dev/null +++ b/src/OptionList.tsx @@ -0,0 +1,184 @@ +import React, { useEffect } from 'react'; +import arrayTreeFilter from 'array-tree-filter'; +import { RefOptionListProps } from 'rc-select/lib/OptionList'; +import { CascaderOption, CascaderFieldNames } from './Cascader'; + +export interface OptionListProps { + prefixCls?: string; + value?: (string | number)[]; + activeValue?: (string | number)[]; + options?: CascaderOption[]; + expandTrigger?: string; + visible?: boolean; + dropdownMenuColumnStyle?: React.CSSProperties; + defaultFieldNames?: CascaderFieldNames; + fieldNames?: CascaderFieldNames; + expandIcon?: React.ReactNode; + loadingIcon?: React.ReactNode; + + onSelect?: (targetOption: string[], index: number, e: React.KeyboardEvent) => void; + onItemDoubleClick?: ( + targetOption: string[], + index: number, + e: React.MouseEvent, + ) => void; +} + +interface MenuItems { + [index: number]: HTMLLIElement; +} + +const OptionList: React.RefForwardingComponent = ( + props, + ref, +) => { + const menuItems: MenuItems = {}; + + let delayTimer: number; + const { prefixCls, dropdownMenuColumnStyle } = props; + + React.useImperativeHandle(ref, () => ({ + onKeyDown: () => {}, + onKeyUp: () => {}, + scrollTo: () => {}, + })); + + const getFieldName = name => { + const { fieldNames, defaultFieldNames } = props; + // 防止只设置单个属性的名字 + return fieldNames[name] || defaultFieldNames[name]; + }; + + const getActiveOptions = (values?: CascaderOption[]): CascaderOption[] => { + const { options } = props; + const activeValue = values || props.activeValue; + return arrayTreeFilter(options, (o, level) => o[getFieldName('value')] === activeValue[level], { + childrenKeyName: getFieldName('children'), + }); + }; + + const getShowOptions = (): CascaderOption[][] => { + const { options } = props; + const result = getActiveOptions() + .map(activeOption => activeOption[getFieldName('children')]) + .filter(activeOption => !!activeOption); + result.unshift(options); + return result; + }; + + const scrollActiveItemToView = () => { + // scroll into view + const optionsLength = getShowOptions().length; + // eslint-disable-next-line no-plusplus + for (let i = 0; i < optionsLength; i++) { + const itemComponent = menuItems[i]; + if (itemComponent && itemComponent.parentElement) { + itemComponent.parentElement.scrollTop = itemComponent.offsetTop; + } + } + }; + + useEffect(() => { + scrollActiveItemToView(); + }, []); + + const delayOnSelect = (onSelect, ...args) => { + if (delayTimer) { + clearTimeout(delayTimer); + delayTimer = null; + } + if (typeof onSelect === 'function') { + delayTimer = window.setTimeout(() => { + onSelect(args); + delayTimer = null; + }, 150); + } + }; + + const isActiveOption = (option, menuIndex) => { + const { activeValue = [] } = props; + return activeValue[menuIndex] === option[getFieldName('value')]; + }; + + const saveMenuItem = index => node => { + menuItems[index] = node; + }; + + const getOption = (option: CascaderOption, menuIndex: number) => { + const { prefixCls, expandTrigger, expandIcon, loadingIcon } = props; + const onSelect = props.onSelect.bind(this, option, menuIndex); + const onItemDoubleClick = props.onItemDoubleClick.bind(this, option, menuIndex); + let expandProps: any = { + onClick: onSelect, + onDoubleClick: onItemDoubleClick, + }; + let menuItemCls = `${prefixCls}-menu-item`; + let expandIconNode = null; + const hasChildren = + option[getFieldName('children')] && option[getFieldName('children')].length > 0; + if (hasChildren || option.isLeaf === false) { + menuItemCls += ` ${prefixCls}-menu-item-expand`; + if (!option.loading) { + expandIconNode = {expandIcon}; + } + } + if (expandTrigger === 'hover' && (hasChildren || option.isLeaf === false)) { + expandProps = { + onMouseEnter: delayOnSelect.bind(this, onSelect), + onMouseLeave: delayOnSelect.bind(this), + onClick: onSelect, + }; + } + if (isActiveOption(option, menuIndex)) { + menuItemCls += ` ${prefixCls}-menu-item-active`; + expandProps.ref = saveMenuItem(menuIndex); + } + if (option.disabled) { + menuItemCls += ` ${prefixCls}-menu-item-disabled`; + } + + let loadingIconNode = null; + if (option.loading) { + menuItemCls += ` ${prefixCls}-menu-item-loading`; + loadingIconNode = loadingIcon || null; + } + + let title = ''; + if ('title' in option) { + // eslint-disable-next-line prefer-destructuring + title = option.title; + } else if (typeof option[getFieldName('label')] === 'string') { + title = option[getFieldName('label')]; + } + + return ( +
  • e.preventDefault()} + > + {option[getFieldName('label')]} + {expandIconNode} + {loadingIconNode} +
  • + ); + }; + + return ( +
    + {getShowOptions().map((options, menuIndex) => ( +
      + {options.map(option => getOption(option, menuIndex))} +
    + ))} +
    + ); +}; + +const RefOptionList = React.forwardRef(OptionList); +RefOptionList.displayName = 'OptionList'; + +export default RefOptionList; diff --git a/src/newCascader.tsx b/src/newCascader.tsx new file mode 100644 index 00000000..4c318cb9 --- /dev/null +++ b/src/newCascader.tsx @@ -0,0 +1,436 @@ +import React from 'react'; +import generateSelector from 'rc-select/lib/generate'; +import { + getLabeledValue as getSelectLabeledValue, + filterOptions as selectDefaultFilterOptions, + isValueDisabled as isSelectValueDisabled, + findValueOption as findSelectValueOption, + flattenOptions, + fillOptionsWithMissingValue, +} from 'rc-select/lib/utils/valueUtil'; + +import warning from 'rc-util/lib/warning'; +import KeyCode from 'rc-util/lib/KeyCode'; +import arrayTreeFilter from 'array-tree-filter'; +import Trigger, { BuildInPlacements, TriggerProps } from 'rc-trigger'; +import { isEqualArrays } from './utils'; +import Menus from './Menus'; +import BUILT_IN_PLACEMENTS from './placements'; +import OptionList from './OptionList'; + +const RefSelect = generateSelector({ + prefixCls: 'rc-cascader', + components: { + optionList: OptionList as any, + }, + convertChildrenToData: () => null, + flattenOptions, + getLabeledValue: getSelectLabeledValue, + filterOptions: selectDefaultFilterOptions, + isValueDisabled: isSelectValueDisabled, + findValueOption: findSelectValueOption, + fillOptionsWithMissingValue, +}); + +RefSelect.displayName = 'Select'; + +export interface CascaderFieldNames { + value?: string | number; + label?: string; + children?: string; +} + +export type CascaderValueType = (string | number)[]; + +export interface CascaderOption { + value?: string | number; + label?: React.ReactNode; + disabled?: boolean; + isLeaf?: boolean; + loading?: boolean; + children?: CascaderOption[]; + [key: string]: any; +} + +export interface CascaderProps extends Pick { + value?: CascaderValueType; + defaultValue?: CascaderValueType; + options?: CascaderOption[]; + onChange?: (value: CascaderValueType, selectOptions: CascaderOption[]) => void; + onPopupVisibleChange?: (popupVisible: boolean) => void; + popupVisible?: boolean; + disabled?: boolean; + transitionName?: string; + popupClassName?: string; + popupPlacement?: string; + prefixCls?: string; + dropdownMenuColumnStyle?: React.CSSProperties; + dropdownRender?: (menu: React.ReactElement) => React.ReactElement; + builtinPlacements?: BuildInPlacements; + loadData?: (selectOptions: CascaderOption[]) => void; + changeOnSelect?: boolean; + children?: React.ReactElement; + onKeyDown?: (e: React.KeyboardEvent) => void; + expandTrigger?: string; + fieldNames?: CascaderFieldNames; + filedNames?: CascaderFieldNames; // typo but for compatibility + expandIcon?: React.ReactNode; + loadingIcon?: React.ReactNode; +} + +interface CascaderState { + popupVisible?: boolean; + activeValue?: CascaderValueType; + value?: CascaderValueType; + prevProps?: CascaderProps; +} + +class RefCascader extends React.Component { + defaultFieldNames: object; + + trigger: any; + + constructor(props: CascaderProps) { + super(props); + let initialValue = []; + if ('value' in props) { + initialValue = props.value || []; + } else if ('defaultValue' in props) { + initialValue = props.defaultValue || []; + } + + warning( + !('filedNames' in props), + '`filedNames` of Cascader is a typo usage and deprecated, please use `fieldNames` instead.', + ); + + this.state = { + popupVisible: props.popupVisible, + activeValue: initialValue, + value: initialValue, + prevProps: props, + }; + this.defaultFieldNames = { + label: 'label', + value: 'value', + children: 'children', + }; + } + + static defaultProps: CascaderProps = { + onChange: () => {}, + onPopupVisibleChange: () => {}, + disabled: false, + transitionName: '', + prefixCls: 'rc-cascader', + popupClassName: '', + popupPlacement: 'bottomLeft', + builtinPlacements: BUILT_IN_PLACEMENTS, + expandTrigger: 'click', + fieldNames: { label: 'label', value: 'value', children: 'children' }, + expandIcon: '>', + }; + + static getDerivedStateFromProps(nextProps: CascaderProps, prevState: CascaderState) { + const { prevProps = {} } = prevState; + const newState: CascaderState = { + prevProps: nextProps, + }; + + if ('value' in nextProps && !isEqualArrays(prevProps.value, nextProps.value)) { + newState.value = nextProps.value || []; + + // allow activeValue diff from value + // https://github.com/ant-design/ant-design/issues/2767 + if (!('loadData' in nextProps)) { + newState.activeValue = nextProps.value || []; + } + } + if ('popupVisible' in nextProps) { + newState.popupVisible = nextProps.popupVisible; + } + + return newState; + } + + getPopupDOMNode() { + return this.trigger.getPopupDomNode(); + } + + getFieldName(name: string): string { + const { defaultFieldNames } = this; + const { fieldNames, filedNames } = this.props; + if ('filedNames' in this.props) { + return filedNames[name] || defaultFieldNames[name]; // For old compatibility + } + return fieldNames[name] || defaultFieldNames[name]; + } + + getFieldNames(): CascaderFieldNames { + const { fieldNames, filedNames } = this.props; + if ('filedNames' in this.props) { + return filedNames; // For old compatibility + } + return fieldNames; + } + + getCurrentLevelOptions(): CascaderOption[] { + const { options = [] } = this.props; + const { activeValue = [] } = this.state; + const result = arrayTreeFilter( + options, + (o, level) => o[this.getFieldName('value')] === activeValue[level], + { childrenKeyName: this.getFieldName('children') }, + ); + if (result[result.length - 2]) { + return result[result.length - 2][this.getFieldName('children')]; + } + return [...options].filter(o => !o.disabled); + } + + getActiveOptions(activeValue: CascaderValueType): CascaderOption[] { + return arrayTreeFilter( + this.props.options || [], + (o, level) => o[this.getFieldName('value')] === activeValue[level], + { childrenKeyName: this.getFieldName('children') }, + ); + } + + setPopupVisible = (popupVisible: boolean) => { + const { value } = this.state; + if (!('popupVisible' in this.props)) { + this.setState({ popupVisible }); + } + // sync activeValue with value when panel open + if (popupVisible && !this.state.popupVisible) { + this.setState({ + activeValue: value, + }); + } + this.props.onPopupVisibleChange(popupVisible); + }; + + handleChange = (options: CascaderOption[], { visible }, e: React.KeyboardEvent) => { + if (e.type !== 'keydown' || e.keyCode === KeyCode.ENTER) { + this.props.onChange(options.map(o => o[this.getFieldName('value')]), options); + this.setPopupVisible(visible); + } + }; + + handlePopupVisibleChange = (popupVisible: boolean) => { + this.setPopupVisible(popupVisible); + }; + + handleMenuSelect = ( + targetOption: CascaderOption, + menuIndex: number, + e: React.KeyboardEvent, + ) => { + // Keep focused state for keyboard support + const triggerNode = this.trigger.getRootDomNode(); + if (triggerNode && triggerNode.focus) { + triggerNode.focus(); + } + const { changeOnSelect, loadData, expandTrigger } = this.props; + if (!targetOption || targetOption.disabled) { + return; + } + let { activeValue } = this.state; + activeValue = activeValue.slice(0, menuIndex + 1); + activeValue[menuIndex] = targetOption[this.getFieldName('value')]; + const activeOptions = this.getActiveOptions(activeValue); + if (targetOption.isLeaf === false && !targetOption[this.getFieldName('children')] && loadData) { + if (changeOnSelect) { + this.handleChange(activeOptions, { visible: true }, e); + } + this.setState({ activeValue }); + loadData(activeOptions); + return; + } + const newState: CascaderState = {}; + if ( + !targetOption[this.getFieldName('children')] || + !targetOption[this.getFieldName('children')].length + ) { + this.handleChange(activeOptions, { visible: false }, e); + // set value to activeValue when select leaf option + newState.value = activeValue; + // add e.type judgement to prevent `onChange` being triggered by mouseEnter + } else if (changeOnSelect && (e.type === 'click' || e.type === 'keydown')) { + if (expandTrigger === 'hover') { + this.handleChange(activeOptions, { visible: false }, e); + } else { + this.handleChange(activeOptions, { visible: true }, e); + } + // set value to activeValue on every select + newState.value = activeValue; + } + newState.activeValue = activeValue; + // not change the value by keyboard + if ('value' in this.props || (e.type === 'keydown' && e.keyCode !== KeyCode.ENTER)) { + delete newState.value; + } + this.setState(newState); + }; + + handleItemDoubleClick = () => { + const { changeOnSelect } = this.props; + if (changeOnSelect) { + this.setPopupVisible(false); + } + }; + + handleKeyDown = (e: React.KeyboardEvent) => { + const { children } = this.props; + // https://github.com/ant-design/ant-design/issues/6717 + // Don't bind keyboard support when children specify the onKeyDown + if (children && children.props.onKeyDown) { + children.props.onKeyDown(e); + return; + } + const activeValue = [...this.state.activeValue]; + const currentLevel = activeValue.length - 1 < 0 ? 0 : activeValue.length - 1; + const currentOptions = this.getCurrentLevelOptions(); + const currentIndex = currentOptions + .map(o => o[this.getFieldName('value')]) + .indexOf(activeValue[currentLevel]); + if ( + e.keyCode !== KeyCode.DOWN && + e.keyCode !== KeyCode.UP && + e.keyCode !== KeyCode.LEFT && + e.keyCode !== KeyCode.RIGHT && + e.keyCode !== KeyCode.ENTER && + e.keyCode !== KeyCode.SPACE && + e.keyCode !== KeyCode.BACKSPACE && + e.keyCode !== KeyCode.ESC && + e.keyCode !== KeyCode.TAB + ) { + return; + } + // Press any keys above to reopen menu + if ( + !this.state.popupVisible && + e.keyCode !== KeyCode.BACKSPACE && + e.keyCode !== KeyCode.LEFT && + e.keyCode !== KeyCode.RIGHT && + e.keyCode !== KeyCode.ESC && + e.keyCode !== KeyCode.TAB + ) { + this.setPopupVisible(true); + return; + } + if (e.keyCode === KeyCode.DOWN || e.keyCode === KeyCode.UP) { + e.preventDefault(); + let nextIndex = currentIndex; + if (nextIndex !== -1) { + if (e.keyCode === KeyCode.DOWN) { + nextIndex += 1; + nextIndex = nextIndex >= currentOptions.length ? 0 : nextIndex; + } else { + nextIndex -= 1; + nextIndex = nextIndex < 0 ? currentOptions.length - 1 : nextIndex; + } + } else { + nextIndex = 0; + } + activeValue[currentLevel] = currentOptions[nextIndex][this.getFieldName('value')]; + } else if (e.keyCode === KeyCode.LEFT || e.keyCode === KeyCode.BACKSPACE) { + e.preventDefault(); + activeValue.splice(activeValue.length - 1, 1); + } else if (e.keyCode === KeyCode.RIGHT) { + e.preventDefault(); + if ( + currentOptions[currentIndex] && + currentOptions[currentIndex][this.getFieldName('children')] + ) { + activeValue.push( + currentOptions[currentIndex][this.getFieldName('children')][0][ + this.getFieldName('value') + ], + ); + } + } else if (e.keyCode === KeyCode.ESC || e.keyCode === KeyCode.TAB) { + this.setPopupVisible(false); + return; + } + if (!activeValue || activeValue.length === 0) { + this.setPopupVisible(false); + } + const activeOptions = this.getActiveOptions(activeValue); + const targetOption = activeOptions[activeOptions.length - 1]; + this.handleMenuSelect(targetOption, activeOptions.length - 1, e); + + if (this.props.onKeyDown) { + this.props.onKeyDown(e); + } + }; + + saveTrigger = node => { + this.trigger = node; + }; + + render() { + const { + prefixCls, + transitionName, + popupClassName, + options = [], + disabled, + builtinPlacements, + popupPlacement, + children, + dropdownRender, + ...restProps + } = this.props; + // Did not show popup when there is no options + let menus =
    ; + let emptyMenuClassName = ''; + if (options && options.length > 0) { + menus = ( + + ); + } else { + emptyMenuClassName = ` ${prefixCls}-menus-empty`; + } + let popupNode = menus; + if (dropdownRender) { + popupNode = dropdownRender(menus); + } + return ( + + {React.cloneElement(children, { + onKeyDown: this.handleKeyDown, + tabIndex: disabled ? undefined : 0, + })} + + ); + } +} + +class Cascader extends React.PureComponent { + render() { + return ; + } +} + +export default Cascader;