diff --git a/.gitignore b/.gitignore index 1fad7848..fb945893 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ lib es yarn.lock package-lock.json +coverage # umi .umi .umi-production diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..7f9f8713 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "endOfLine": "lf", + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "all", + "printWidth": 100, + "arrowParens": "avoid" +} \ No newline at end of file diff --git a/README.md b/README.md index 8e1fece3..475a182f 100644 --- a/README.md +++ b/README.md @@ -143,13 +143,13 @@ React.render( expand current item when click or hover - popupVisible + open Boolean visibility of popup overlay - onPopupVisibleChange + onDropdownVisibleChange Function(visible) callback when popup overlay's visibility changed diff --git a/assets/index.less b/assets/index.less index b93d9bab..eb16af54 100644 --- a/assets/index.less +++ b/assets/index.less @@ -1,166 +1,2 @@ -.effect() { - animation-duration: .3s; - animation-fill-mode: both; - transform-origin: 0 0; -} - -.rc-cascader { - font-size: 12px; - &-menus { - font-size: 12px; - overflow: hidden; - background: #fff; - position: absolute; - border: 1px solid #d9d9d9; - border-radius: 6px; - box-shadow: 0 0 4px rgba(0,0,0,0.17); - white-space: nowrap; - - &-hidden { - display: none; - } - - &.slide-up-enter, &.slide-up-appear { - .effect(); - opacity: 0; - animation-timing-function: cubic-bezier(0.08, 0.82, 0.17, 1); - animation-play-state: paused; - } - - &.slide-up-leave { - .effect(); - opacity: 1; - animation-timing-function: cubic-bezier(0.6, 0.04, 0.98, 0.34); - animation-play-state: paused; - } - - &.slide-up-enter.slide-up-enter-active&-placement-bottomLeft, - &.slide-up-appear.slide-up-appear-active&-placement-bottomLeft { - animation-name: SlideUpIn; - animation-play-state: running; - } - - &.slide-up-enter.slide-up-enter-active&-placement-topLeft, - &.slide-up-appear.slide-up-appear-active&-placement-topLeft { - animation-name: SlideDownIn; - animation-play-state: running; - } - - &.slide-up-leave.slide-up-leave-active&-placement-bottomLeft { - animation-name: SlideUpOut; - animation-play-state: running; - } - - &.slide-up-leave.slide-up-leave-active&-placement-topLeft { - animation-name: SlideDownOut; - animation-play-state: running; - } - } - &-menu { - display: inline-block; - width: 100px; - height: 192px; - list-style: none; - margin: 0; - padding: 0; - border-right: 1px solid #e9e9e9; - overflow: auto; - &:last-child { - border-right: 0; - } - } - &-menu-item { - height: 32px; - line-height: 32px; - padding: 0 16px; - cursor: pointer; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - transition: all 0.3s ease; - position: relative; - &:hover { - background: tint(#2db7f5, 90%); - } - &-disabled { - cursor: not-allowed; - color: #ccc; - &:hover { - background: transparent; - } - } - &-loading:after { - position: absolute; - right: 12px; - content: 'loading'; - color: #aaa; - font-style: italic; - } - &-active { - background: tint(#2db7f5, 80%); - &:hover { - background: tint(#2db7f5, 80%); - } - } - &-expand { - position: relative; - &-icon { - font-size: 12px; - color: #999; - position: absolute; - right: 16px; - line-height: 32px; - } - } - } -} - -@keyframes SlideUpIn { - 0% { - opacity: 0; - transform-origin: 0% 0%; - transform: scaleY(.8); - } - 100% { - opacity: 1; - transform-origin: 0% 0%; - transform: scaleY(1); - } -} -@keyframes SlideUpOut { - 0% { - opacity: 1; - transform-origin: 0% 0%; - transform: scaleY(1); - } - 100% { - opacity: 0; - transform-origin: 0% 0%; - transform: scaleY(0.8); - } -} - -@keyframes SlideDownIn { - 0% { - opacity: 0; - transform-origin: 0% 100%; - transform: scaleY(0.8); - } - 100% { - opacity: 1; - transform-origin: 0% 100%; - transform: scaleY(1); - } -} -@keyframes SlideDownOut { - 0% { - opacity: 1; - transform-origin: 0% 100%; - transform: scaleY(1); - } - 100% { - opacity: 0; - transform-origin: 0% 100%; - transform: scaleY(0.8); - } -} \ No newline at end of file +@import "./select.less"; +@import "./list.less"; \ No newline at end of file diff --git a/assets/list.less b/assets/list.less new file mode 100644 index 00000000..afab02c9 --- /dev/null +++ b/assets/list.less @@ -0,0 +1,99 @@ +@select-prefix: ~'rc-cascader'; + +.@{select-prefix} { + &-dropdown { + min-height: auto; + } + + &-holder { + display: flex; + flex-wrap: nowrap; + } + + &-menu { + flex: none; + margin: 0; + padding: 0; + list-style: none; + border-left: 1px solid blue; + + &:first-child { + border-left: 0; + } + + &-item { + display: flex; + flex-wrap: nowrap; + padding-right: 20px; + position: relative; + + &-selected { + background: rgba(0, 0, 255, 0.05); + } + + &-active { + background: rgba(0, 255, 0, 0.1); + } + + &-disabled { + opacity: 0.5; + } + + + + &-content { + flex: auto; + } + + &-expand-icon { + position: absolute; + right: 4px; + top: 50%; + transform: translateY(-50%); + } + } + } + + &-checkbox { + position: relative; + display: block; + flex: none; + width: 20px; + height: 20px; + border: 1px solid blue; + + &::after { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + content: ''; + } + + &-checked::after { + content: '✔️'; + } + + &-indeterminate::after { + content: '➖'; + } + } + + // ====================== RTL ====================== + &-rtl { + direction: rtl; + + .@{select-prefix}-menu { + flex: none; + margin: 0; + padding: 0; + list-style: none; + border-left: none; + border-right: 1px solid blue; + + &:first-child { + border-right: 0; + } + } + } +} diff --git a/assets/select.less b/assets/select.less new file mode 100644 index 00000000..8f2cd8fa --- /dev/null +++ b/assets/select.less @@ -0,0 +1,3 @@ +@import '~rc-select/assets/index'; + +@select-prefix: ~'rc-cascader'; \ No newline at end of file diff --git a/docs/demo/debug.md b/docs/demo/debug.md new file mode 100644 index 00000000..6dd26237 --- /dev/null +++ b/docs/demo/debug.md @@ -0,0 +1,3 @@ +## debug + + diff --git a/examples/animation.jsx b/examples/animation.jsx index 1b3eceb8..ff512aa5 100644 --- a/examples/animation.jsx +++ b/examples/animation.jsx @@ -70,7 +70,7 @@ class Demo extends React.Component { render() { return ( - + ); diff --git a/examples/debug.tsx b/examples/debug.tsx new file mode 100644 index 00000000..477ae5bf --- /dev/null +++ b/examples/debug.tsx @@ -0,0 +1,114 @@ +/* eslint-disable no-console */ +import React from 'react'; +import '../assets/index.less'; +import Cascader from '../src'; + +const addressOptions = [ + { + label: '福建', + value: 'fj', + title: '测试标题', + children: [ + { + label: '福州', + value: 'fuzhou', + children: [ + { + label: '马尾', + value: 'mawei', + }, + ], + }, + { + label: '泉州', + value: 'quanzhou', + }, + ], + }, + { + label: '浙江', + value: 'zj', + children: [ + { + label: '杭州', + value: 'hangzhou', + children: [ + { + label: '余杭', + value: 'yuhang', + }, + ], + }, + ], + }, + { + label: '北京', + value: 'bj', + children: [ + { + label: '朝阳区', + value: 'chaoyang', + }, + { + label: '海淀区', + value: 'haidian', + disabled: true, + }, + { + label: 'TEST', + value: 'test', + }, + ], + }, + { + label: '顶层禁用', + value: 'disabled', + disabled: true, + children: [ + { + label: '看不见', + value: 'invisible', + }, + ], + }, +]; + +const defaultValue = ['fj', 'fuzhou']; + +const Demo = () => { + const [multiple, setMultiple] = React.useState(true); + const [, setInputValue] = React.useState(''); + + const onChange = (value: any, selectedOptions: any) => { + console.log('[DEBUG] onChange - value:', value); + console.log('[DEBUG] onChange - selectedOptions:', selectedOptions); + setInputValue(selectedOptions.map(o => o.label).join(', ')); + }; + + return ( + <> + + + + ); +}; + +export default Demo; diff --git a/examples/dynamic-options.jsx b/examples/dynamic-options.jsx index 2420286f..5957a6ec 100644 --- a/examples/dynamic-options.jsx +++ b/examples/dynamic-options.jsx @@ -23,13 +23,14 @@ class Demo extends React.Component { }; onChange = (value, selectedOptions) => { - console.log(value, selectedOptions); + console.log('OnChange:', value, selectedOptions); this.setState({ inputValue: selectedOptions.map(o => o.label).join(', '), }); }; loadData = selectedOptions => { + console.log('onLoad:', selectedOptions); const targetOption = selectedOptions[selectedOptions.length - 1]; targetOption.loading = true; // 动态加载下级数据 @@ -49,7 +50,7 @@ class Demo extends React.Component { // eslint-disable-next-line react/no-access-state-in-setstate options: [...this.state.options], }); - }, 1000); + }, 3000); }; render() { diff --git a/jest.config.js b/jest.config.js index 8d42d5be..76d617fc 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,3 +1,4 @@ module.exports = { + setupFiles: ['./tests/setup.js'], snapshotSerializers: [require.resolve('enzyme-to-json/serializer')], } diff --git a/package.json b/package.json index 4a7caa91..53d48231 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "dependencies": { "@babel/runtime": "^7.12.5", "array-tree-filter": "^2.1.0", + "rc-tree-select": "~4.4.0-alpha.3", "rc-trigger": "^5.0.4", "rc-util": "^5.0.1", "warning": "^4.0.1" diff --git a/src/Cascader.tsx b/src/Cascader.tsx index cecfd53c..a73c4d09 100644 --- a/src/Cascader.tsx +++ b/src/Cascader.tsx @@ -1,415 +1,339 @@ import * as React from 'react'; -import type { BuildInPlacements, TriggerProps } from 'rc-trigger'; -import Trigger from 'rc-trigger'; -import warning from 'warning'; -import KeyCode from 'rc-util/lib/KeyCode'; -import arrayTreeFilter from 'array-tree-filter'; -import { isEqualArrays } from './utils'; -import Menus from './Menus'; -import BUILT_IN_PLACEMENTS from './placements'; - -export interface CascaderFieldNames { - value?: string | number; - label?: string; - children?: string; -} +import warning from 'rc-util/lib/warning'; +import useMergedState from 'rc-util/lib/hooks/useMergedState'; +import type { TreeSelectProps } from 'rc-tree-select'; +import generate from 'rc-tree-select/lib/generate'; +import type { FlattenDataNode } from 'rc-tree-select/lib/interface'; +import { fillFieldNames } from 'rc-tree-select/lib/utils/valueUtil'; +import type { RefSelectProps } from 'rc-select/lib/generate'; +import OptionList from './OptionList'; +import type { CascaderValueType, DataNode, FieldNames, ShowSearchType } from './interface'; +import CascaderContext from './context'; +import { connectValue, convertOptions, restoreCompatibleValue } from './util'; +import useUpdateEffect from './hooks/useUpdateEffect'; +import useSearchConfig from './hooks/useSearchConfig'; + +/** + * `rc-cascader` is much like `rc-tree-select` but API is very different. + * It's caused that component developer is not same person + * and we do not rice the API naming standard at that time. + * + * To avoid breaking change, wrap the `rc-tree-select` to compatible with `rc-cascader` API. + * This should be better to merge to same API like `rc-tree-select` or `rc-select` in next major version. + * + * Update: + * - dropdown class change to `rc-cascader-dropdown` + * - direction rtl keyboard + * + * Deprecated: + * - popupVisible + * - hidePopupOnSelect + * + * Removed: + * - builtinPlacements: Handle by select + */ + +const RefCascader = generate({ + prefixCls: 'rc-cascader', + optionList: OptionList, +}); -export type CascaderValueType = (string | number)[]; +// ====================================== Wrap ====================================== +interface BaseCascaderProps + extends Omit< + TreeSelectProps, + | 'value' + | 'defaultValue' + | 'filterTreeNode' + | 'labelInValue' + | 'loadData' + | 'multiple' + | 'showCheckedStrategy' + | 'showSearch' + | 'treeCheckable' + | 'treeCheckStrictly' + | 'treeDataSimpleMode' + | 'treeNodeFilterProp' + | 'treeNodeLabelProp' + | 'treeDefaultExpandAll' + | 'treeDefaultExpandedKeys' + | 'treeExpandedKeys' + | 'treeIcon' + | 'onChange' + > { + options?: DataNode[]; + children?: React.ReactElement; -export interface CascaderOption { - value?: string | number; - label?: React.ReactNode; + // Value + value?: CascaderValueType | CascaderValueType[]; + defaultValue?: CascaderValueType | CascaderValueType[]; + changeOnSelect?: boolean; + allowClear?: boolean; 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; + fieldNames?: FieldNames; + + // Search + showSearch?: boolean | ShowSearchType; + searchValue?: string; + onSearch?: (search: string) => void; + + // Open + /** @deprecated Use `open` instead */ popupVisible?: boolean; - disabled?: boolean; - transitionName?: string; - popupClassName?: string; - popupPlacement?: string; - prefixCls?: string; + + /** @deprecated Use `onDropdownVisibleChange` instead */ + onPopupVisibleChange?: (open: boolean) => void; + onDropdownVisibleChange?: (open: boolean) => void; + + // Trigger + expandTrigger?: 'hover' | 'click'; + 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 + loadData?: (selectOptions: DataNode[]) => void; + expandIcon?: React.ReactNode; loadingIcon?: React.ReactNode; - hidePopupOnSelect?: boolean; } -interface CascaderState { - popupVisible?: boolean; - activeValue?: CascaderValueType; - value?: CascaderValueType; - prevProps?: CascaderProps; +type OnSingleChange = (value: CascaderValueType, selectOptions: DataNode[]) => void; +type OnMultipleChange = (value: CascaderValueType[], selectOptions: DataNode[][]) => void; + +interface SingleCascaderProps extends BaseCascaderProps { + checkable?: false; + + onChange?: OnSingleChange; } -class Cascader extends React.Component { - defaultFieldNames: Record; +interface MultipleCascaderProps extends BaseCascaderProps { + checkable: true | React.ReactNode; - trigger: any; + onChange?: OnMultipleChange; +} - constructor(props: CascaderProps) { - super(props); - let initialValue = []; - if ('value' in props) { - initialValue = props.value || []; - } else if ('defaultValue' in props) { - initialValue = props.defaultValue || []; - } +export type CascaderProps = SingleCascaderProps | MultipleCascaderProps; - warning( - !('filedNames' in props), - '`filedNames` of Cascader is a typo usage and deprecated, please use `fieldNames` instead.', - ); +interface CascaderRef { + focus: () => void; + blur: () => void; +} - this.state = { - popupVisible: props.popupVisible, - activeValue: initialValue, - value: initialValue, - prevProps: props, - }; - this.defaultFieldNames = { - label: 'label', - value: 'value', - children: 'children', - }; - } +const Cascader = React.forwardRef((props: CascaderProps, ref: React.Ref) => { + const { + checkable, - 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: '>', - hidePopupOnSelect: true, - }; + changeOnSelect, + children, + options, + onChange, + value, + defaultValue, - static getDerivedStateFromProps(nextProps: CascaderProps, prevState: CascaderState) { - const { prevProps = {} } = prevState; - const newState: CascaderState = { - prevProps: nextProps, - }; + popupVisible, + open, + onDropdownVisibleChange, + onPopupVisibleChange, - if ('value' in nextProps && !isEqualArrays(prevProps.value, nextProps.value)) { - newState.value = nextProps.value || []; + searchValue, + onSearch, + showSearch, - // 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; - } + expandTrigger, + expandIcon = '>', + loadingIcon, - return newState; - } + loadData, + dropdownMenuColumnStyle, - getPopupDOMNode() { - return this.trigger.getPopupDomNode(); - } + ...restProps + } = props; - 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]; - } + const { fieldNames } = restProps; - getFieldNames(): CascaderFieldNames { - const { fieldNames, filedNames } = this.props; - if ('filedNames' in this.props) { - return filedNames; // For old compatibility - } - return fieldNames; - } + const mergedFieldNames = React.useMemo(() => fillFieldNames(fieldNames), [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); - } + // ============================ Ref ============================= + const cascaderRef = React.useRef(); - getActiveOptions(activeValue: CascaderValueType): CascaderOption[] { - return arrayTreeFilter( - this.props.options || [], - (o, level) => o[this.getFieldName('value')] === activeValue[level], - { childrenKeyName: this.getFieldName('children') }, - ); - } + React.useImperativeHandle(ref, () => ({ + focus: () => { + cascaderRef.current.focus(); + }, + blur: () => { + cascaderRef.current.blur(); + }, + })); - 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, - }); + const getEntityByValue = (val: React.Key): FlattenDataNode => + (cascaderRef.current as any).getEntityByValue(val); + + // =========================== Search =========================== + const [mergedSearch, setMergedSearch] = useMergedState(undefined, { + value: searchValue, + onChange: onSearch, + }); + + const [mergedShowSearch, searchConfig] = useSearchConfig(showSearch); + + // ========================== Options =========================== + const mergedOptions = React.useMemo(() => { + return convertOptions(options, mergedFieldNames); + }, [options, mergedFieldNames]); + + // =========================== Value ============================ + /** + * Always pass props value to last value unit: + * - single: ['light', 'little'] => ['light__little'] + * - multiple: [['light', 'little'], ['bamboo']] => ['light__little', 'bamboo'] + */ + const parseToInternalValue = ( + propValue?: CascaderValueType | CascaderValueType[], + ): React.Key[] => { + let propValueList: CascaderValueType[] = []; + if (propValue) { + propValueList = (checkable ? propValue : [propValue]) as CascaderValueType[]; } - this.props.onPopupVisibleChange(popupVisible); + + return propValueList.map(connectValue); }; - 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); + const [internalValue, setInternalValue] = React.useState(() => + parseToInternalValue(value || defaultValue), + ); + + useUpdateEffect(() => { + setInternalValue(parseToInternalValue(value)); + }, [value]); + + // =========================== Label ============================ + const labelRender = (entity: FlattenDataNode) => { + const { label: fieldLabel } = mergedFieldNames; + + if (checkable) { + return entity.data.node[fieldLabel]; } - }; - handlePopupVisibleChange = (popupVisible: boolean) => { - this.setPopupVisible(popupVisible); + return restoreCompatibleValue(entity, mergedFieldNames) + .options.map(opt => opt[fieldLabel]) + .join('>'); }; - 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, hidePopupOnSelect } = 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; + // =========================== Change =========================== + const onInternalChange = (newValue: any /** Not care current type */) => { + // TODO: Need improve motion experience + setMergedSearch(''); + + const valueList = (checkable ? newValue : [newValue]) as React.Key[]; + + const pathList: CascaderValueType[] = []; + const optionsList: DataNode[][] = []; + + const valueEntities = valueList.map(getEntityByValue).filter(entity => entity); + + valueEntities.forEach(entity => { + const { options: valueOptions } = restoreCompatibleValue(entity, mergedFieldNames); + const originOptions = valueOptions.map(option => option.node); + + pathList.push(originOptions.map(opt => opt[mergedFieldNames.value])); + optionsList.push(originOptions); + }); + + // Fill state + if (value === undefined) { + setInternalValue(valueList); } - const newState: CascaderState = {}; - if ( - !targetOption[this.getFieldName('children')] || - !targetOption[this.getFieldName('children')].length - ) { - this.handleChange(activeOptions, { visible: !hidePopupOnSelect }, 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: !hidePopupOnSelect }, e); + + if (onChange) { + if (checkable) { + (onChange as OnMultipleChange)(pathList, optionsList); } else { - this.handleChange(activeOptions, { visible: true }, e); + // TODO: This should return null as other component. + // But its a breaking change and we should keep the logic. + (onChange as OnSingleChange)(pathList[0] || [], optionsList[0] || []); } - // 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); - } - }; + // ============================ Open ============================ + if (process.env.NODE_ENV !== 'production') { + warning( + !onPopupVisibleChange, + '`onPopupVisibleChange` is deprecated. Please use `onDropdownVisibleChange` instead.', + ); + warning(popupVisible === undefined, '`popupVisible` is deprecated. Please use `open` instead.'); + } - 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); - if (this.props.onKeyDown) { - this.props.onKeyDown(e); - } - 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); - if (this.props.onKeyDown) { - this.props.onKeyDown(e); - } - 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); + const mergedOpen = open !== undefined ? open : popupVisible; - if (this.props.onKeyDown) { - this.props.onKeyDown(e); - } + const onInternalDropdownVisibleChange = (nextVisible: boolean) => { + onDropdownVisibleChange?.(nextVisible); + onPopupVisibleChange?.(nextVisible); }; - saveTrigger = (node) => { - this.trigger = node; - }; + // ========================== Context =========================== + const context = React.useMemo( + () => ({ + changeOnSelect, + expandTrigger, + fieldNames: mergedFieldNames, + expandIcon, + loadingIcon, + loadData, + dropdownMenuColumnStyle, + search: searchConfig, + }), + [ + changeOnSelect, + expandTrigger, + mergedFieldNames, + expandIcon, + loadingIcon, + loadData, + dropdownMenuColumnStyle, + searchConfig, + ], + ); - 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, - })} - - ); - } -} + value={checkable ? internalValue : internalValue[0]} + dropdownMatchSelectWidth={false} + dropdownStyle={dropdownStyle} + treeData={mergedOptions} + treeCheckable={checkable} + treeNodeFilterProp="label" + onChange={onInternalChange} + showCheckedStrategy={RefCascader.SHOW_PARENT} + open={mergedOpen} + onDropdownVisibleChange={onInternalDropdownVisibleChange} + searchValue={mergedSearch} + // Customize filter logic in OptionList + filterTreeNode={() => true} + showSearch={mergedShowSearch} + onSearch={setMergedSearch} + labelRender={labelRender} + {...{ + getRawInputElement: () => children, + }} + /> + + ); +}); + +Cascader.displayName = 'Cascader'; export default Cascader; diff --git a/src/Menus.tsx b/src/Menus.tsx deleted file mode 100644 index 41074088..00000000 --- a/src/Menus.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import * as React from 'react'; -import arrayTreeFilter from 'array-tree-filter'; -import type { CascaderOption, CascaderFieldNames } from './Cascader'; - -interface MenusProps { - value?: (string | number)[]; - activeValue?: (string | number)[]; - options?: CascaderOption[]; - prefixCls?: string; - expandTrigger?: string; - onSelect?: (targetOption: string[], index: number, e: React.KeyboardEvent) => void; - visible?: boolean; - dropdownMenuColumnStyle?: React.CSSProperties; - defaultFieldNames?: CascaderFieldNames; - fieldNames?: CascaderFieldNames; - expandIcon?: React.ReactNode; - loadingIcon?: React.ReactNode; - onItemDoubleClick?: ( - targetOption: string[], - index: number, - e: React.MouseEvent, - ) => void; -} - -type MenuItems = Record; - -class Menus extends React.Component { - menuItems: MenuItems = {}; - - delayTimer: number; - - static defaultProps: MenusProps = { - options: [], - value: [], - activeValue: [], - onSelect() {}, - prefixCls: 'rc-cascader-menus', - visible: false, - expandTrigger: 'click', - }; - - componentDidMount() { - this.scrollActiveItemToView(); - } - - componentDidUpdate(prevProps: MenusProps) { - if (!prevProps.visible && this.props.visible) { - this.scrollActiveItemToView(); - } - } - - getFieldName(name) { - const { fieldNames, defaultFieldNames } = this.props; - // 防止只设置单个属性的名字 - return fieldNames[name] || defaultFieldNames[name]; - } - - getOption(option: CascaderOption, menuIndex: number) { - const { prefixCls, expandTrigger, expandIcon, loadingIcon } = this.props; - const onSelect = this.props.onSelect.bind(this, option, menuIndex); - const onItemDoubleClick = this.props.onItemDoubleClick.bind(this, option, menuIndex); - let expandProps: any = { - onClick: onSelect, - onDoubleClick: onItemDoubleClick, - }; - let menuItemCls = `${prefixCls}-menu-item`; - let expandIconNode = null; - const hasChildren = - option[this.getFieldName('children')] && option[this.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: this.delayOnSelect.bind(this, onSelect), - onMouseLeave: this.delayOnSelect.bind(this), - onClick: onSelect, - }; - } - if (this.isActiveOption(option, menuIndex)) { - menuItemCls += ` ${prefixCls}-menu-item-active`; - expandProps.ref = this.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[this.getFieldName('label')] === 'string') { - title = option[this.getFieldName('label')]; - } - - return ( -
  • e.preventDefault()} - > - {option[this.getFieldName('label')]} - {expandIconNode} - {loadingIconNode} -
  • - ); - } - - getActiveOptions(values?: CascaderOption[]): CascaderOption[] { - const { options } = this.props; - const activeValue = values || this.props.activeValue; - return arrayTreeFilter( - options, - (o, level) => o[this.getFieldName('value')] === activeValue[level], - { childrenKeyName: this.getFieldName('children') }, - ); - } - - getShowOptions(): CascaderOption[][] { - const { options } = this.props; - const result = this.getActiveOptions() - .map((activeOption) => activeOption[this.getFieldName('children')]) - .filter((activeOption) => !!activeOption && activeOption.length > 0); - result.unshift(options); - return result; - } - - delayOnSelect(onSelect, ...args) { - if (this.delayTimer) { - clearTimeout(this.delayTimer); - this.delayTimer = null; - } - if (typeof onSelect === 'function') { - this.delayTimer = window.setTimeout(() => { - onSelect(args); - this.delayTimer = null; - }, 150); - } - } - - scrollActiveItemToView() { - // scroll into view - const optionsLength = this.getShowOptions().length; - // eslint-disable-next-line no-plusplus - for (let i = 0; i < optionsLength; i++) { - const itemComponent = this.menuItems[i]; - if (itemComponent && itemComponent.parentElement) { - itemComponent.parentElement.scrollTop = itemComponent.offsetTop; - } - } - } - - isActiveOption(option, menuIndex) { - const { activeValue = [] } = this.props; - return activeValue[menuIndex] === option[this.getFieldName('value')]; - } - - saveMenuItem = (index) => (node) => { - this.menuItems[index] = node; - }; - - render() { - const { prefixCls, dropdownMenuColumnStyle } = this.props; - return ( -
    - {this.getShowOptions().map((options, menuIndex) => ( -
      - {options.map((option) => this.getOption(option, menuIndex))} -
    - ))} -
    - ); - } -} - -export default Menus; diff --git a/src/OptionList/Checkbox.tsx b/src/OptionList/Checkbox.tsx new file mode 100644 index 00000000..60220785 --- /dev/null +++ b/src/OptionList/Checkbox.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import { SelectContext } from 'rc-tree-select/lib/Context'; + +export interface CheckboxProps { + prefixCls: string; + checked?: boolean; + halfChecked?: boolean; + disabled?: boolean; + onClick?: React.MouseEventHandler; +} + +export default function Checkbox({ + prefixCls, + checked, + halfChecked, + disabled, + onClick, +}: CheckboxProps) { + const { checkable } = React.useContext(SelectContext); + + const customCheckbox = typeof checkable !== 'boolean' ? checkable : null; + + return ( + + {customCheckbox} + + ); +} diff --git a/src/OptionList/Column.tsx b/src/OptionList/Column.tsx new file mode 100644 index 00000000..b74d586c --- /dev/null +++ b/src/OptionList/Column.tsx @@ -0,0 +1,128 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import type { OptionDataNode } from '../interface'; +import { isLeaf } from '../util'; +import CascaderContext from '../context'; +import Checkbox from './Checkbox'; + +export interface ColumnProps { + prefixCls: string; + index: number; + multiple?: boolean; + options: OptionDataNode[]; + /** Current Column opened item key */ + openKey?: React.Key; + onSelect: (value: React.Key, isLeaf: boolean) => void; + onOpen: (index: number, value: React.Key) => void; + onToggleOpen: (open: boolean) => void; + checkedSet: Set; + halfCheckedSet: Set; + loadingKeys: React.Key[]; +} + +export default function Column({ + prefixCls, + index, + multiple, + options, + openKey, + onSelect, + onOpen, + onToggleOpen, + checkedSet, + halfCheckedSet, + loadingKeys, +}: ColumnProps) { + const menuPrefixCls = `${prefixCls}-menu`; + const menuItemPrefixCls = `${prefixCls}-menu-item`; + + const { changeOnSelect, expandTrigger, expandIcon, loadingIcon, dropdownMenuColumnStyle } = + React.useContext(CascaderContext); + + // ============================ Render ============================ + return ( +
      + {options.map(option => { + const { disabled, value, node } = option; + const isMergedLeaf = isLeaf(option); + + const isLoading = loadingKeys.includes(value); + + // >>>>> checked + const checked = checkedSet.has(value); + + // >>>>> Open + const triggerOpenPath = () => { + if (!disabled && !isMergedLeaf) { + onOpen(index, value); + } + }; + + // >>>>> Selection + const triggerSelect = () => { + if (!disabled && (isMergedLeaf || changeOnSelect || multiple)) { + onSelect(value, isMergedLeaf); + } + }; + + // >>>>> Title + let title: string; + if (typeof node?.title === 'string') { + title = node.title; + } else if (typeof option.title === 'string') { + title = option.title; + } + + // >>>>> Render + return ( +
    • { + triggerOpenPath(); + if (!multiple || isMergedLeaf) { + triggerSelect(); + } + }} + onDoubleClick={() => { + if (changeOnSelect) { + onToggleOpen(false); + } + }} + onMouseEnter={() => { + if (expandTrigger) { + triggerOpenPath(); + } + }} + > + {multiple && ( + { + triggerSelect(); + }} + /> + )} +
      {option.title}
      + {!isLoading && expandIcon && !isMergedLeaf && ( +
      {expandIcon}
      + )} + {isLoading && loadingIcon && ( +
      {loadingIcon}
      + )} +
    • + ); + })} +
    + ); +} diff --git a/src/OptionList/index.tsx b/src/OptionList/index.tsx new file mode 100644 index 00000000..edbe3bbe --- /dev/null +++ b/src/OptionList/index.tsx @@ -0,0 +1,332 @@ +/* eslint-disable default-case */ +import * as React from 'react'; +import classNames from 'classnames'; +import KeyCode from 'rc-util/lib/KeyCode'; +import type { + OptionListProps as SelectOptionListProps, + RefOptionListProps, +} from 'rc-select/lib/OptionList'; +import { SelectContext } from 'rc-tree-select/lib/Context'; +import type { OptionDataNode } from '../interface'; +import Column from './Column'; +import { isLeaf, restoreCompatibleValue } from '../util'; +import CascaderContext from '../context'; +import useSearchResult from '../hooks/useSearchResult'; + +type OptionListProps = SelectOptionListProps; +export type FlattenOptions = OptionListProps['flattenOptions']; + +const RefOptionList = React.forwardRef((props, ref) => { + const { + prefixCls, + options, + onSelect, + multiple, + open, + flattenOptions, + searchValue, + onToggleOpen, + notFoundContent, + direction, + } = props; + + const rtl = direction === 'rtl'; + + const { checkedKeys, halfCheckedKeys } = React.useContext(SelectContext); + const { changeOnSelect, expandTrigger, fieldNames, loadData, search } = + React.useContext(CascaderContext); + + // ========================= loadData ========================= + const [loadingKeys, setLoadingKeys] = React.useState([]); + + const internalLoadData = (pathValue: React.Key) => { + // Do not load when search + if (!loadData || searchValue) { + return; + } + + const entity = flattenOptions.find(flattenOption => flattenOption.data.value === pathValue); + if (entity) { + const { options: optionList } = restoreCompatibleValue(entity as any, fieldNames); + const rawOptionList = optionList.map(opt => opt.node); + + setLoadingKeys(keys => [...keys, optionList[optionList.length - 1].value]); + + loadData(rawOptionList); + } + }; + + // zombieJ: This is bad. We should make this same as `rc-tree` to use Promise instead. + React.useEffect(() => { + if (loadingKeys.length) { + loadingKeys.forEach(loadingKey => { + const option = flattenOptions.find(opt => opt.value === loadingKey); + if (option.data.children) { + setLoadingKeys(keys => keys.filter(key => key !== loadingKey)); + } + }); + } + }, [flattenOptions, loadingKeys]); + + // ========================== Values ========================== + const checkedSet = React.useMemo(() => new Set(checkedKeys), [checkedKeys]); + const halfCheckedSet = React.useMemo(() => new Set(halfCheckedKeys), [halfCheckedKeys]); + + // =========================== Open =========================== + const [openPath, setOpenPath] = React.useState([]); + + React.useEffect(() => { + if (open) { + let nextOpenPath: React.Key[] = []; + + if (!multiple && checkedKeys.length) { + const entity = flattenOptions.find( + flattenOption => flattenOption.data.value === checkedKeys[0], + ); + + if (entity) { + nextOpenPath = restoreCompatibleValue(entity as any, fieldNames).path; + } + } + setOpenPath(nextOpenPath); + } + }, [open]); + + // =========================== Path =========================== + const onPathOpen = (index: number, pathValue: React.Key) => { + const nextOpenPath = [...openPath.slice(0, index), pathValue]; + setOpenPath(nextOpenPath); + + // Trigger loadData + internalLoadData(pathValue); + }; + + const onPathSelect = (pathValue: React.Key, leaf: boolean) => { + onSelect(pathValue, { selected: !checkedSet.has(pathValue) }); + + if (!multiple && (leaf || (changeOnSelect && expandTrigger === 'hover'))) { + onToggleOpen(false); + } + }; + + const getPathList = (pathList: React.Key[]) => { + let currentOptions = options; + + for (let i = 0; i < pathList.length; i += 1) { + currentOptions = (currentOptions || []).find(option => option.value === pathList[i]).children; + } + + return currentOptions; + }; + + // ========================== Search ========================== + const searchOptions = useSearchResult({ + ...props, + fieldNames, + changeOnSelect, + searchConfig: search, + }); + + // ========================== Column ========================== + const optionColumns = React.useMemo(() => { + if (searchValue) { + return [ + { + options: searchOptions, + }, + ]; + } + + const rawOptionColumns: { + options: OptionDataNode[]; + }[] = []; + + for (let i = 0; i <= openPath.length; i += 1) { + const subOptions = getPathList(openPath.slice(0, i)); + + if (subOptions) { + rawOptionColumns.push({ + options: subOptions, + }); + } else { + break; + } + } + + return rawOptionColumns; + }, [searchValue, searchOptions, openPath]); + + // ========================= Keyboard ========================= + const getActiveOption = (activeColumnIndex: number, offset: number) => { + const pathActiveValue = openPath[activeColumnIndex]; + const currentOptions = optionColumns[activeColumnIndex]?.options || []; + let activeOptionIndex = currentOptions.findIndex(opt => opt.value === pathActiveValue); + + const len = currentOptions.length; + + // Last one is special since -1 may back 2 offset + if (offset === -1 && activeOptionIndex === -1) { + activeOptionIndex = len; + } + + for (let i = 1; i <= len; i += 1) { + const current = (activeOptionIndex + i * offset + len) % len; + const option = currentOptions[current]; + + if (!option.disabled) { + return option; + } + } + + return null; + }; + + const prevColumn = () => { + if (openPath.length <= 1) { + onToggleOpen(false); + } + setOpenPath(openPath.slice(0, -1)); + }; + + const nextColumn = () => { + const nextColumnIndex = openPath.length; + const nextActiveOption = getActiveOption(nextColumnIndex, 1); + if (nextActiveOption) { + onPathOpen(nextColumnIndex, nextActiveOption.value); + } + }; + + React.useImperativeHandle(ref, () => ({ + // scrollTo: treeRef.current?.scrollTo, + onKeyDown: event => { + const { which } = event; + + switch (which) { + // >>> Arrow keys + case KeyCode.UP: + case KeyCode.DOWN: { + let offset = 0; + if (which === KeyCode.UP) { + offset = -1; + } else if (which === KeyCode.DOWN) { + offset = 1; + } + + if (offset !== 0) { + const activeColumnIndex = Math.max(openPath.length - 1, 0); + const nextActiveOption = getActiveOption(activeColumnIndex, offset); + if (nextActiveOption) { + onPathOpen(activeColumnIndex, nextActiveOption.value); + } + } + + break; + } + + case KeyCode.LEFT: { + if (rtl) { + nextColumn(); + } else { + prevColumn(); + } + break; + } + + case KeyCode.RIGHT: { + if (rtl) { + prevColumn(); + } else { + nextColumn(); + } + break; + } + + case KeyCode.BACKSPACE: { + prevColumn(); + break; + } + + // >>> Select + case KeyCode.ENTER: { + const lastValue = openPath[openPath.length - 1]; + const option = optionColumns[openPath.length - 1].options?.find( + opt => opt.value === lastValue, + ); + + const leaf = isLeaf(option); + + if (multiple || changeOnSelect || leaf) { + onPathSelect(lastValue, leaf); + } + + // Close for changeOnSelect + if (changeOnSelect) { + onToggleOpen(false); + } + break; + } + + // >>> Close + case KeyCode.ESC: { + onToggleOpen(false); + + if (open) { + event.stopPropagation(); + } + } + } + }, + onKeyUp: () => {}, + })); + + // ========================== Render ========================== + const columnProps = { + ...props, + onOpen: onPathOpen, + onSelect: onPathSelect, + onToggleOpen, + checkedSet, + halfCheckedSet, + loadingKeys, + }; + + // >>>>> Empty + const emptyList: OptionDataNode[] = [ + { + title: notFoundContent, + value: '__EMPTY__', + disabled: true, + node: null, + }, + ]; + + // >>>>> Columns + const mergedOptionColumns = optionColumns[0]?.options?.length + ? optionColumns + : [{ options: emptyList }]; + + const columnNodes: React.ReactElement[] = mergedOptionColumns.map((col, index) => ( + + )); + + // >>>>> Render + return ( + <> +
    + {columnNodes} +
    + + ); +}); + +export default RefOptionList; diff --git a/src/context.ts b/src/context.ts new file mode 100644 index 00000000..b373dfb7 --- /dev/null +++ b/src/context.ts @@ -0,0 +1,31 @@ +import * as React from 'react'; +import type { CascaderProps } from './Cascader'; +import type { ShowSearchType } from './interface'; + +const CascaderContext = React.createContext< + Required< + Pick< + CascaderProps, + | 'changeOnSelect' + | 'expandTrigger' + | 'fieldNames' + | 'expandIcon' + | 'loadingIcon' + | 'loadData' + | 'dropdownMenuColumnStyle' + > & { + search: ShowSearchType; + } + > +>({ + changeOnSelect: false, + expandTrigger: 'click', + fieldNames: null, + expandIcon: null, + loadingIcon: null, + loadData: null, + dropdownMenuColumnStyle: null, + search: null, +}); + +export default CascaderContext; diff --git a/src/hooks/useSearchConfig.ts b/src/hooks/useSearchConfig.ts new file mode 100644 index 00000000..e27b754b --- /dev/null +++ b/src/hooks/useSearchConfig.ts @@ -0,0 +1,26 @@ +import * as React from 'react'; +import type { ShowSearchType } from '../interface'; +import type { CascaderProps } from '../Cascader'; + +// Convert `showSearch` to unique config +export default function useSearchConfig(showSearch?: CascaderProps['showSearch']) { + return React.useMemo<[boolean, ShowSearchType]>(() => { + if (!showSearch) { + return [false, {}]; + } + + let searchConfig: ShowSearchType = { + matchInputWidth: true, + limit: 50, + }; + + if (showSearch && typeof showSearch === 'object') { + searchConfig = { + ...searchConfig, + ...showSearch, + }; + } + + return [true, searchConfig]; + }, [showSearch]); +} diff --git a/src/hooks/useSearchResult.ts b/src/hooks/useSearchResult.ts new file mode 100644 index 00000000..55172d44 --- /dev/null +++ b/src/hooks/useSearchResult.ts @@ -0,0 +1,89 @@ +import * as React from 'react'; +import type { FlattenOptions } from '../OptionList'; +import type { FieldNames, ShowSearchType } from '../interface'; +import { isLeaf, restoreCompatibleValue } from '../util'; + +const defaultFilter: ShowSearchType['filter'] = (search, options, { label }) => + options.some(opt => String(opt[label]).toLowerCase().includes(search.toLowerCase())); + +const defaultRender: ShowSearchType['render'] = (inputValue, path, prefixCls, fieldNames) => + path.map(opt => opt[fieldNames.label]).join(' / '); + +export interface SearchResultConfig { + prefixCls: string; + searchConfig: ShowSearchType; + flattenOptions: FlattenOptions; + fieldNames: FieldNames; + changeOnSelect: boolean; + searchValue: string; +} + +export default function useSearchResult(resultConfig: SearchResultConfig) { + const { searchConfig, flattenOptions, fieldNames, changeOnSelect, searchValue, prefixCls } = + resultConfig; + + // ============================== MISC ============================== + const filterOption = searchConfig.filter || defaultFilter; + const renderOption = searchConfig.render || defaultRender; + + // ============================= Filter ============================= + // Do filter + const filteredEntityList = React.useMemo(() => { + if (!searchValue) { + return []; + } + + // Normalize list + let normalizeList = flattenOptions.map(option => { + const { options } = restoreCompatibleValue(option as any, fieldNames); + const originOptionList = options.map(opt => opt.node); + return { + option, + originOptionList, + }; + }); + + // Not keep parent node when !changeOnSelect + if (!changeOnSelect) { + normalizeList = normalizeList.filter(({ option }) => isLeaf(option.data)); + } + + // Filter + const filteredList: typeof normalizeList = []; + for (let i = 0; i < normalizeList.length; i += 1) { + // Perf saving if enabled + if (!searchConfig.sort && filteredList.length >= searchConfig.limit) { + break; + } + + // Do filter + const optGrp = normalizeList[i]; + if (filterOption(searchValue, optGrp.originOptionList, fieldNames)) { + filteredList.push(optGrp); + } + } + + // Sort: When searchConfig.sort is enabled. We have to filter all the list + if (searchConfig.sort) { + filteredList.sort((a, b) => { + return searchConfig.sort(a.originOptionList, b.originOptionList, searchValue, fieldNames); + }); + } + + return filteredList; + }, [flattenOptions, fieldNames, searchValue, filterOption, changeOnSelect, searchConfig]); + + // ======================== Generate Options ======================== + // Wrap with connected label + const options = React.useMemo(() => { + return filteredEntityList.map(({ option, originOptionList }) => { + const title = renderOption(searchValue, originOptionList, prefixCls, fieldNames); + return { + ...option.data, + title, + }; + }); + }, [searchValue, renderOption, filteredEntityList, fieldNames, prefixCls]); + + return options; +} diff --git a/src/hooks/useUpdateEffect.ts b/src/hooks/useUpdateEffect.ts new file mode 100644 index 00000000..fe9e88a0 --- /dev/null +++ b/src/hooks/useUpdateEffect.ts @@ -0,0 +1,18 @@ +import * as React from 'react'; + +/** + * Work like `componentDidUpdate` + */ +export default function useUpdateEffect(updater: () => void, condition?: any[]) { + const prepareRef = React.useRef(true); + + React.useEffect(() => { + if (prepareRef.current) { + prepareRef.current = false; + return; + } + + // eslint-disable-next-line consistent-return + return updater(); + }, condition); +} diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 49414075..00000000 --- a/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Cascader from './Cascader'; - -export default Cascader; diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 00000000..9e19ae0b --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,3 @@ +import Cascader from './Cascader' + +export default Cascader; \ No newline at end of file diff --git a/src/interface.ts b/src/interface.ts new file mode 100644 index 00000000..351be96e --- /dev/null +++ b/src/interface.ts @@ -0,0 +1,52 @@ +import type * as React from 'react'; + +export interface FieldNames { + value?: string; + label?: string; + children?: string; +} + +export type CascaderValueType = React.Key[]; + +export interface DataNode { + label: React.ReactNode; + /** Customize hover title */ + title?: string; + value: string; + disabled?: boolean; + children?: DataNode[]; + isLeaf?: boolean; +} + +export interface InternalDataNode extends DataNode { + node: DataNode; +} + +export interface OptionDataNode extends Omit { + title: React.ReactNode; + children?: OptionDataNode[]; +} +export interface FlattenDataNode { + key: React.Key; + data: DataNode; + path: React.Key[]; + parent?: FlattenDataNode; +} + +export interface ShowSearchType { + filter?: (inputValue: string, options: DataNode[], fieldNames: FieldNames) => boolean; + render?: ( + inputValue: string, + path: DataNode[], + prefixCls: string, + fieldNames: FieldNames, + ) => React.ReactNode; + sort?: ( + a: DataNode[], + b: DataNode[], + inputValue: string, + fieldNames: FieldNames, + ) => number; + matchInputWidth?: boolean; + limit?: number | false; +} diff --git a/src/placements.ts b/src/placements.ts deleted file mode 100644 index 7b04f8b8..00000000 --- a/src/placements.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { BuildInPlacements } from 'rc-trigger'; - -const BUILT_IN_PLACEMENTS: BuildInPlacements = { - bottomLeft: { - points: ['tl', 'bl'], - offset: [0, 4], - overflow: { - adjustX: 1, - adjustY: 1, - }, - }, - topLeft: { - points: ['bl', 'tl'], - offset: [0, -4], - overflow: { - adjustX: 1, - adjustY: 1, - }, - }, - bottomRight: { - points: ['tr', 'br'], - offset: [0, 4], - overflow: { - adjustX: 1, - adjustY: 1, - }, - }, - topRight: { - points: ['br', 'tr'], - offset: [0, -4], - overflow: { - adjustX: 1, - adjustY: 1, - }, - }, -}; - -export default BUILT_IN_PLACEMENTS; diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 00000000..92f5c4de --- /dev/null +++ b/src/util.ts @@ -0,0 +1,85 @@ +import type { FlattenDataNode } from 'rc-tree-select/lib/interface'; +import type { + CascaderValueType, + DataNode, + FieldNames, + InternalDataNode, + OptionDataNode, +} from './interface'; + +const VALUE_SPLIT = '__RC_CASCADER_SPLIT__'; + +/** + * Convert entity back to path & options + * @returns + */ +export function restoreCompatibleValue( + entity: FlattenDataNode, + fieldNames: FieldNames, +): { + path: CascaderValueType; + options: InternalDataNode[]; +} { + const path: CascaderValueType = []; + const options: InternalDataNode[] = []; + + let current = entity; + + while (current) { + path.unshift(current.data.node[fieldNames.value]); + options.unshift(current.data.node as any as InternalDataNode); + + current = current.parent; + } + + return { path, options }; +} + +export function isLeaf(option: OptionDataNode) { + const { children, node } = option; + + const leaf = node?.isLeaf; + + return leaf !== undefined ? leaf : !children?.length; +} + +/** + * We will connect path value to a string. e.g. + * ['little', 'bamboo'] => 'little__bamboo' + * + * zombieJ: It's better to deprecate the same key in the nest tree. Maybe next major version. + */ +export function connectValue(value: React.Key[]) { + return (value || []).join(VALUE_SPLIT); +} + +/** + * Fill options with fully value by path to avoid nest entity with same value. + * Which means we need another round to get origin node back! + * This is slow perf on large list. We should abandon same value in nest in future. + */ +export function convertOptions( + options: DataNode[], + { value: fieldValue, children: fieldChildren }: FieldNames, +): InternalDataNode[] { + function injectValue(list: DataNode[], parentValue = ''): InternalDataNode[] { + return (list || []).map(option => { + const newValue = parentValue + ? connectValue([parentValue, option[fieldValue]]) + : option[fieldValue]; + const cloneOption = { + ...option, + [fieldValue]: newValue, + node: option, + }; + + if (cloneOption[fieldChildren]) { + cloneOption[fieldChildren] = injectValue(cloneOption[fieldChildren], newValue); + } + + return cloneOption; + }); + } + + return injectValue(options); +} diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index 9370080c..00000000 --- a/src/utils.ts +++ /dev/null @@ -1,24 +0,0 @@ -export function isEqualArrays(arrA: any[], arrB: any[]): boolean { - if (arrA === arrB) { - return true; - } - - if (!arrA || !arrB) { - return false; - } - - const len = arrA.length; - - if (arrB.length !== len) { - return false; - } - - // eslint-disable-next-line no-plusplus - for (let i = 0; i < len; i++) { - if (arrA[i] !== arrB[i]) { - return false; - } - } - - return true; -} diff --git a/tests/__snapshots__/index.spec.js.snap b/tests/__snapshots__/index.spec.js.snap index da7f0379..1dad94b9 100644 --- a/tests/__snapshots__/index.spec.js.snap +++ b/tests/__snapshots__/index.spec.js.snap @@ -1,281 +1,137 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Cascader should not show title when title is falsy 1`] = ` +exports[`Cascader.Basic should not show title when title is falsy 1`] = ` `; -exports[`Cascader should render custom dropdown correctly 1`] = ` +exports[`Cascader.Basic should render custom dropdown correctly 1`] = `
    -
    - +
    -
    +
    +
    > - +
    +
    > - +
    +
    > - +
    - -
    - - Hello, DropdownRender - +
    + + Hello, DropdownRender + +
    `; diff --git a/tests/checkable.spec.tsx b/tests/checkable.spec.tsx new file mode 100644 index 00000000..dc2750ac --- /dev/null +++ b/tests/checkable.spec.tsx @@ -0,0 +1,79 @@ +/* eslint-disable react/jsx-no-bind */ + +import React from 'react'; +import { mount } from './enzyme'; +import Cascader from '../src'; + +describe('Cascader.Checkable', () => { + const options = [ + { + label: 'Light', + value: 'light', + }, + { + label: 'Bamboo', + value: 'bamboo', + children: [ + { + label: 'Little', + value: 'little', + children: [ + { + label: 'Toy Fish', + value: 'fish', + }, + { + label: 'Toy Cards', + value: 'cards', + }, + ], + }, + ], + }, + ]; + + it('customize', () => { + const onChange = jest.fn(); + const wrapper = mount(); + + expect(wrapper.exists('.rc-cascader-checkbox')).toBeTruthy(); + expect(wrapper.exists('.rc-cascader-checkbox-checked')).toBeFalsy(); + expect(wrapper.exists('.rc-cascader-checkbox-indeterminate')).toBeFalsy(); + + // Check light + wrapper.find('.rc-cascader-checkbox').first().simulate('click'); + expect(wrapper.exists('.rc-cascader-checkbox-checked')).toBeTruthy(); + expect(onChange).toHaveBeenCalledWith( + [['light']], + [[expect.objectContaining({ value: 'light' })]], + ); + + onChange.mockReset(); + + // Open bamboo > little + wrapper.clickOption(0, 1); + wrapper.clickOption(1, 0); + + // Check cards + wrapper.clickOption(2, 1); + expect(wrapper.exists('.rc-cascader-checkbox-indeterminate')).toBeTruthy(); + expect(onChange).toHaveBeenCalledWith( + [ + // Light + ['light'], + // Cards + ['bamboo', 'little', 'cards'], + ], + [ + // Light + [expect.objectContaining({ value: 'light' })], + // Cards + [ + expect.objectContaining({ value: 'bamboo' }), + expect.objectContaining({ value: 'little' }), + expect.objectContaining({ value: 'cards' }), + ], + ], + ); + }); +}); diff --git a/tests/demoOptions.js b/tests/demoOptions.js index f454d58d..d121659e 100644 --- a/tests/demoOptions.js +++ b/tests/demoOptions.js @@ -1,98 +1,140 @@ -export const optionsForActiveMenuItems = [{ - value: '1', - label: '1', - children: [{ +export const optionsForActiveMenuItems = [ + { value: '1', label: '1', - }, { + children: [ + { + value: '1', + label: '1', + }, + { + value: '2', + label: '2', + }, + ], + }, + { value: '2', label: '2', - }], -}, { - value: '2', - label: '2', - children: [{ - value: '1', - label: '1', - }, { - value: '2', - label: '2', - }, { - value: '3', - label: '3', - }], -}]; + children: [ + { + value: '1', + label: '1', + }, + { + value: '2', + label: '2', + }, + { + value: '3', + label: '3', + }, + ], + }, +]; -export const addressOptions = [{ - label: '福建', - value: 'fj', - children: [{ - label: '福州', - value: 'fuzhou', - children: [{ - label: '马尾', - value: 'mawei', - }], - }, { - label: '泉州', - value: 'quanzhou', - }], -}, { - label: '浙江', - value: 'zj', - children: [{ - label: '杭州', - value: 'hangzhou', - children: [{ - label: '余杭', - value: 'yuhang', - }], - }], -}, { - label: '北京', - value: 'bj', - children: [{ - label: '朝阳区', - value: 'chaoyang', - }, { - label: '海淀区', - value: 'haidian', - }], -}]; +export const addressOptions = [ + { + label: '福建', + value: 'fj', + children: [ + { + label: '福州', + value: 'fuzhou', + children: [ + { + label: '马尾', + value: 'mawei', + }, + ], + }, + { + label: '泉州', + value: 'quanzhou', + }, + ], + }, + { + label: '浙江', + value: 'zj', + children: [ + { + label: '杭州', + value: 'hangzhou', + children: [ + { + label: '余杭', + value: 'yuhang', + }, + ], + }, + ], + }, + { + label: '北京', + value: 'bj', + children: [ + { + label: '朝阳区', + value: 'chaoyang', + }, + { + label: '海淀区', + value: 'haidian', + }, + ], + }, +]; -export const addressOptionsForFieldNames = [{ - name: '福建', - code: 'fj', - nodes: [{ - name: '福州', - code: 'fuzhou', - nodes: [{ - name: '马尾', - code: 'mawei', - }], - }, { - name: '泉州', - code: 'quanzhou', - }], -}, { - name: '浙江', - code: 'zj', - nodes: [{ - name: '杭州', - code: 'hangzhou', - nodes: [{ - name: '余杭', - code: 'yuhang', - }], - }], -}, { - name: '北京', - code: 'bj', - nodes: [{ - name: '朝阳区', - code: 'chaoyang', - }, { - name: '海淀区', - code: 'haidian', - }], -}]; +export const addressOptionsForFieldNames = [ + { + name: '福建', + code: 'fj', + nodes: [ + { + name: '福州', + code: 'fuzhou', + nodes: [ + { + name: '马尾', + code: 'mawei', + }, + ], + }, + { + name: '泉州', + code: 'quanzhou', + }, + ], + }, + { + name: '浙江', + code: 'zj', + nodes: [ + { + name: '杭州', + code: 'hangzhou', + nodes: [ + { + name: '余杭', + code: 'yuhang', + }, + ], + }, + ], + }, + { + name: '北京', + code: 'bj', + nodes: [ + { + name: '朝阳区', + code: 'chaoyang', + }, + { + name: '海淀区', + code: 'haidian', + }, + ], + }, +]; diff --git a/tests/enzyme.ts b/tests/enzyme.ts new file mode 100644 index 00000000..273fb0df --- /dev/null +++ b/tests/enzyme.ts @@ -0,0 +1,15 @@ +import { mount as originMount } from 'enzyme'; +import type { ReactWrapper as OriginReactWrapper } from 'enzyme'; + +type ReplaceReturnType any, TNewReturn> = ( + ...a: Parameters +) => TNewReturn; + +export interface ReactWrapper extends OriginReactWrapper { + isOpen: () => boolean; + clickOption: (menuIndex: number, itemIndex: number, type?: 'click' | 'doubleClick') => ReactWrapper; +} + +type Mount = ReplaceReturnType; + +export const mount = originMount as Mount; diff --git a/tests/fieldNames.spec.tsx b/tests/fieldNames.spec.tsx new file mode 100644 index 00000000..87274a20 --- /dev/null +++ b/tests/fieldNames.spec.tsx @@ -0,0 +1,87 @@ +/* eslint-disable react/jsx-no-bind */ + +import React from 'react'; +import { mount } from './enzyme'; +import Cascader from '../src'; + +describe('Cascader.FieldNames', () => { + const options = [ + { + customTitle: 'Light', + customValue: 'light', + }, + { + customTitle: 'Bamboo', + customValue: 'bamboo', + customChildren: [ + { + customTitle: 'Little', + customValue: 'little', + customChildren: [ + { + customTitle: 'Toy', + customValue: 'toy', + }, + ], + }, + ], + }, + ] as any; + + const fieldNames = { + label: 'customTitle', + value: 'customValue', + children: 'customChildren', + }; + + it('customize', () => { + const onChange = jest.fn(); + const wrapper = mount( + , + ); + + // Open + wrapper.find('.rc-cascader-selector').simulate('mousedown'); + expect(wrapper.isOpen()).toBeTruthy(); + + // Check values + expect(wrapper.find('.rc-cascader-menu')).toHaveLength(1); + expect(wrapper.find('.rc-cascader-menu').at(0).find('.rc-cascader-menu-item')).toHaveLength(2); + + // Click Bamboo + wrapper.clickOption(0, 1); + expect(wrapper.find('.rc-cascader-menu')).toHaveLength(2); + expect(wrapper.find('.rc-cascader-menu').at(1).find('.rc-cascader-menu-item')).toHaveLength(1); + + // Click Little & Toy + wrapper.clickOption(1, 0); + wrapper.clickOption(2, 0); + + expect(onChange).toHaveBeenCalledWith( + ['bamboo', 'little', 'toy'], + [ + expect.objectContaining({ customTitle: 'Bamboo', customValue: 'bamboo' }), + expect.objectContaining({ customTitle: 'Little', customValue: 'little' }), + expect.objectContaining({ customTitle: 'Toy', customValue: 'toy' }), + ], + ); + }); + + it('defaultValue', () => { + const wrapper = mount( + , + ); + + expect(wrapper.find('.rc-cascader-menu')).toHaveLength(3); + expect(wrapper.find('.rc-cascader-menu-item-active')).toHaveLength(3); + expect(wrapper.find('.rc-cascader-menu-item-active').at(0).text()).toEqual('Bamboo'); + expect(wrapper.find('.rc-cascader-menu-item-active').at(1).text()).toEqual('Little'); + expect(wrapper.find('.rc-cascader-menu-item-active').at(2).text()).toEqual('Toy'); + }); +}); diff --git a/tests/index.spec.js b/tests/index.spec.js index ded5f1e4..62108ca3 100644 --- a/tests/index.spec.js +++ b/tests/index.spec.js @@ -1,30 +1,13 @@ /* eslint-disable react/jsx-no-bind */ import React from 'react'; +import { resetWarned } from 'rc-util/lib/warning'; +import { spyElementPrototypes } from 'rc-util/lib/test/domHook'; import { mount } from 'enzyme'; import Cascader from '..'; -import { - addressOptions, - optionsForActiveMenuItems, - addressOptionsForFieldNames, -} from './demoOptions'; - -export const createDocumentListenersMock = () => { - const listeners = {}; - const handler = (domEl, event) => listeners?.[event]?.({ target: domEl }); - document.addEventListener = jest.fn((event, cb) => { - listeners[event] = cb; - }); - document.removeEventListener = jest.fn((event) => { - delete listeners[event]; - }); - return { - mouseDown: (domEl) => handler(domEl, 'mousedown'), - click: (domEl) => handler(domEl, 'click'), - }; -}; +import { addressOptions, optionsForActiveMenuItems } from './demoOptions'; -describe('Cascader', () => { +describe('Cascader.Basic', () => { let selectedValue; const onChange = function onChange(value) { selectedValue = value; @@ -39,19 +22,18 @@ describe('Cascader', () => { jest.useRealTimers(); }); - const fireEvent = createDocumentListenersMock(); - it('should toggle select panel when click it', () => { const wrapper = mount( , ); - expect(wrapper.state().popupVisible).toBeFalsy(); + + expect(wrapper.isOpen()).toBeFalsy(); wrapper.find('input').simulate('click'); - expect(wrapper.state().popupVisible).toBeTruthy(); + expect(wrapper.isOpen()).toBeTruthy(); wrapper.find('input').simulate('click'); - expect(wrapper.state().popupVisible).toBeFalsy(); + expect(wrapper.isOpen()).toBeFalsy(); }); it('should call onChange when finish select', () => { @@ -61,23 +43,28 @@ describe('Cascader', () => { , ); wrapper.find('input').simulate('click'); + + // Menu 1 let menus = wrapper.find('.rc-cascader-menu'); expect(menus.length).toBe(1); const menu1Items = menus.at(0).find('.rc-cascader-menu-item'); expect(menu1Items.length).toBe(3); expect(selectedValue).toBeFalsy(); - menu1Items.at(0).simulate('click'); + + wrapper.clickOption(0, 0); expect( wrapper.find('.rc-cascader-menu-item').first().hasClass('rc-cascader-menu-item-active'), - ).toBe(true); + ).toBeTruthy(); + + // Menu 2 menus = wrapper.find('.rc-cascader-menu'); expect(menus.length).toBe(2); const menu2Items = menus.at(1).find('.rc-cascader-menu-item'); expect(menu2Items.length).toBe(2); - expect(wrapper.state().popupVisible).toBeTruthy(); + expect(wrapper.isOpen()).toBeTruthy(); expect(selectedValue).toBeFalsy(); - menu2Items.at(0).simulate('click'); + wrapper.clickOption(1, 0); expect( wrapper .find('.rc-cascader-menu') @@ -85,16 +72,18 @@ describe('Cascader', () => { .find('.rc-cascader-menu-item') .first() .hasClass('rc-cascader-menu-item-active'), - ).toBe(true); + ).toBeTruthy(); + + // Menu 3 menus = wrapper.find('.rc-cascader-menu'); expect(menus.length).toBe(3); const menu3Items = menus.at(2).find('.rc-cascader-menu-item'); expect(menu3Items.length).toBe(1); - expect(wrapper.state().popupVisible).toBeTruthy(); + expect(wrapper.isOpen()).toBeTruthy(); expect(selectedValue).toBeFalsy(); - menu3Items.at(0).simulate('click'); - expect(wrapper.state().popupVisible).toBeFalsy(); + wrapper.clickOption(2, 0); + expect(wrapper.isOpen()).toBeFalsy(); expect(selectedValue.join(',')).toBe('fj,fuzhou,mawei'); }); @@ -126,6 +115,8 @@ describe('Cascader', () => { , ); wrapper.find('input').simulate('click'); + + // Menu 1 let menus = wrapper.find('.rc-cascader-menu'); expect(menus.length).toBe(1); const menu1Items = menus.at(0).find('.rc-cascader-menu-item'); @@ -142,12 +133,14 @@ describe('Cascader', () => { .find('.rc-cascader-menu-item') .first() .hasClass('rc-cascader-menu-item-active'), - ).toBe(true); + ).toBeTruthy(); + + // Menu 2 menus = wrapper.find('.rc-cascader-menu'); expect(menus.length).toBe(2); const menu2Items = menus.at(1).find('.rc-cascader-menu-item'); expect(menu2Items.length).toBe(2); - expect(wrapper.state().popupVisible).toBeTruthy(); + expect(wrapper.isOpen()).toBeTruthy(); expect(selectedValue).toBeFalsy(); menu2Items.at(0).simulate('mouseEnter'); @@ -160,16 +153,18 @@ describe('Cascader', () => { .find('.rc-cascader-menu-item') .first() .hasClass('rc-cascader-menu-item-active'), - ).toBe(true); + ).toBeTruthy(); + + // Menu 3 menus = wrapper.find('.rc-cascader-menu'); expect(menus.length).toBe(3); const menu3Items = menus.at(2).find('.rc-cascader-menu-item'); expect(menu3Items.length).toBe(1); - expect(wrapper.state().popupVisible).toBeTruthy(); + expect(wrapper.isOpen()).toBeTruthy(); expect(selectedValue).toBeFalsy(); - menu3Items.at(0).simulate('click'); - expect(wrapper.state().popupVisible).toBeFalsy(); + wrapper.clickOption(2, 0); + expect(wrapper.isOpen()).toBeFalsy(); expect(selectedValue.join(',')).toBe('fj,fuzhou,mawei'); }); @@ -181,14 +176,16 @@ describe('Cascader', () => { ); wrapper.find('input').simulate('click'); let menus = wrapper.find('.rc-cascader-menu'); - const menu1Items = menus.at(0).find('.rc-cascader-menu-item'); - menu1Items.at(0).simulate('click'); + wrapper.clickOption(0, 0); menus = wrapper.find('.rc-cascader-menu'); expect(menus.length).toBe(2); + wrapper.find('input').simulate('click'); - expect(wrapper.state().popupVisible).toBeFalsy(); + expect(wrapper.isOpen()).toBeFalsy(); + wrapper.find('input').simulate('click'); - expect(wrapper.state().popupVisible).toBeTruthy(); + expect(wrapper.isOpen()).toBeTruthy(); + menus = wrapper.find('.rc-cascader-menu'); expect(menus.length).toBe(1); }); @@ -202,14 +199,16 @@ describe('Cascader', () => { wrapper.find('input').simulate('click'); let menus = wrapper.find('.rc-cascader-menu'); expect(menus.length).toBe(3); - const menu1Items = menus.at(0).find('.rc-cascader-menu-item'); - menu1Items.at(0).simulate('click'); + + wrapper.clickOption(0, 0); menus = wrapper.find('.rc-cascader-menu'); expect(menus.length).toBe(2); + wrapper.find('input').simulate('click'); - expect(wrapper.state().popupVisible).toBeFalsy(); + expect(wrapper.isOpen()).toBeFalsy(); + wrapper.find('input').simulate('click'); - expect(wrapper.state().popupVisible).toBeTruthy(); + expect(wrapper.isOpen()).toBeTruthy(); menus = wrapper.find('.rc-cascader-menu'); expect(menus.length).toBe(3); }); @@ -228,14 +227,16 @@ describe('Cascader', () => { wrapper.find('input').simulate('click'); let menus = wrapper.find('.rc-cascader-menu'); expect(menus.length).toBe(3); - const menu1Items = menus.at(0).find('.rc-cascader-menu-item'); - menu1Items.at(0).simulate('click'); + + wrapper.clickOption(0, 0); menus = wrapper.find('.rc-cascader-menu'); expect(menus.length).toBe(2); + wrapper.find('input').simulate('click'); - expect(wrapper.state().popupVisible).toBeFalsy(); + expect(wrapper.isOpen()).toBeFalsy(); + wrapper.find('input').simulate('click'); - expect(wrapper.state().popupVisible).toBeTruthy(); + expect(wrapper.isOpen()).toBeTruthy(); menus = wrapper.find('.rc-cascader-menu'); expect(menus.length).toBe(2); expect(selectedValue.length).toBe(1); @@ -251,23 +252,21 @@ describe('Cascader', () => { wrapper.find('input').simulate('click'); let menus = wrapper.find('.rc-cascader-menu'); expect(menus.length).toBe(2); - const menu1Items = menus.at(0).find('.rc-cascader-menu-item'); - menu1Items.at(0).simulate('click'); + wrapper.clickOption(0, 0); menus = wrapper.find('.rc-cascader-menu'); expect(menus.length).toBe(2); - const menu2Items = menus.at(1).find('.rc-cascader-menu-item'); - menu2Items.at(0).simulate('click'); + wrapper.clickOption(1, 0); menus = wrapper.find('.rc-cascader-menu'); expect(menus.length).toBe(3); - const menu3Items = menus.at(2).find('.rc-cascader-menu-item'); - menu3Items.at(0).simulate('click'); - expect(wrapper.state().popupVisible).toBeFalsy(); + wrapper.clickOption(2, 0); + expect(wrapper.isOpen()).toBeFalsy(); + wrapper.find('input').simulate('click'); menus = wrapper.find('.rc-cascader-menu'); - expect(wrapper.state().popupVisible).toBeTruthy(); + expect(wrapper.isOpen()).toBeTruthy(); expect(menus.length).toBe(2); }); @@ -277,25 +276,27 @@ describe('Cascader', () => { , ); - expect(wrapper.state().popupVisible).toBeFalsy(); + expect(wrapper.isOpen()).toBeFalsy(); wrapper.find('input').simulate('click'); - expect(wrapper.state().popupVisible).toBeFalsy(); + expect(wrapper.isOpen()).toBeFalsy(); wrapper.find('input').simulate('click'); - expect(wrapper.state().popupVisible).toBeFalsy(); + expect(wrapper.isOpen()).toBeFalsy(); }); - it('should not display popup when there is no options', () => { + it('should display not found popup when there is no options', () => { const wrapper = mount( , ); wrapper.find('input').simulate('click'); - let menus = wrapper.find('.rc-cascader-menu'); - expect(menus.length).toBe(0); - wrapper.find('input').simulate('click'); - menus = wrapper.find('.rc-cascader-menu'); - expect(menus.length).toBe(0); + expect(wrapper.isOpen()).toBeTruthy(); + expect(wrapper.find('.rc-cascader-menu')).toHaveLength(1); + expect(wrapper.find('.rc-cascader-menu-item')).toHaveLength(1); + expect(wrapper.find('.rc-cascader-menu-item').text()).toEqual('Not Found'); + + wrapper.setProps({ notFoundContent: 'BambooLight' }); + expect(wrapper.find('.rc-cascader-menu-item').text()).toEqual('BambooLight'); }); it('should not display when children is empty', () => { @@ -379,19 +380,9 @@ describe('Cascader', () => { clearTimeout(this.timeout); } - getPopupDOMNode() { - return this.cascader.getPopupDOMNode(); - } - render() { return ( - { - this.cascader = node; - }} - > + ); @@ -401,8 +392,8 @@ describe('Cascader', () => { wrapper.find('input').simulate('click'); let menus = wrapper.find('.rc-cascader-menu'); expect(menus.length).toBe(1); - const menu1Items = menus.at(0).find('.rc-cascader-menu-item'); - menu1Items.at(0).simulate('click'); + + wrapper.clickOption(0, 0); menus = wrapper.find('.rc-cascader-menu'); expect(menus.length).toBe(2); @@ -425,9 +416,10 @@ describe('Cascader', () => { expect(menus.length).toBe(1); const menu1Items = menus.at(0).find('.rc-cascader-menu-item'); expect(menu1Items.length).toBe(3); - menu1Items.at(0).simulate('click'); + + wrapper.clickOption(0, 0); expect(selectedValue[0]).toBe('fj'); - expect(wrapper.state().popupVisible).toBeFalsy(); + expect(wrapper.isOpen()).toBeFalsy(); }); it('should not call onChange on hover when expandTrigger=hover with changeOnSelect', () => { @@ -446,67 +438,23 @@ describe('Cascader', () => { jest.runAllTimers(); wrapper.update(); expect(selectedValue).toBeFalsy(); - expect(wrapper.state().popupVisible).toBeTruthy(); + expect(wrapper.isOpen()).toBeTruthy(); }); - it('should has default fieldName when props not exist labelField and valueField and childrenField', () => { - const wrapper = mount( - - - , - ); - const props = wrapper.props(); - expect(props.fieldNames.label).toBe('label'); - expect(props.fieldNames.value).toBe('value'); - expect(props.fieldNames.children).toBe('children'); - }); + it('warning popupVisible & onPopupVisibleChange', () => { + resetWarned(); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const onPopupVisibleChange = jest.fn(); + mount(); - it('should support custom fieldNames', () => { - const wrapper = mount( - - - , + expect(errorSpy).toHaveBeenCalledWith( + 'Warning: `onPopupVisibleChange` is deprecated. Please use `onDropdownVisibleChange` instead.', ); - const props = wrapper.props(); - expect(props.fieldNames.label).toBe('name'); - expect(props.fieldNames.value).toBe('code'); - expect(props.fieldNames.children).toBe('nodes'); - const activeMenuItems = wrapper.find('.rc-cascader-menu-item-active'); - expect(activeMenuItems.length).toBe(3); - expect(activeMenuItems.at(0).text()).toBe('福建'); - expect(activeMenuItems.at(1).text()).toBe('福州'); - expect(activeMenuItems.at(2).text()).toBe('马尾'); - }); - - it('should works and show warning message when use typo prop name: filedNames', () => { - // eslint-disable-next-line no-console - console.error = jest.fn(); - const wrapper = mount( - - - , + expect(errorSpy).toHaveBeenCalledWith( + 'Warning: `popupVisible` is deprecated. Please use `open` instead.', ); - // eslint-disable-next-line no-console - expect(console.error).toHaveBeenCalled(); - const activeMenuItems = wrapper.find('.rc-cascader-menu-item-active'); - expect(activeMenuItems.length).toBe(3); - expect(activeMenuItems.at(0).text()).toBe('福建'); - expect(activeMenuItems.at(1).text()).toBe('福州'); - expect(activeMenuItems.at(2).text()).toBe('马尾'); - // eslint-disable-next-line no-console - console.error.mockClear(); + + errorSpy.mockRestore(); }); it('should support custom expand icon(text icon)', () => { @@ -537,59 +485,86 @@ describe('Cascader', () => { , ); - expect(wrapper.state().popupVisible).toBeFalsy(); + expect(wrapper.isOpen()).toBeFalsy(); wrapper.find('input').simulate('click'); - expect(wrapper.state().popupVisible).toBeTruthy(); - wrapper.find('li').at(0).simulate('doubleClick'); - expect(wrapper.state().popupVisible).toBeFalsy(); + expect(wrapper.isOpen()).toBeTruthy(); + wrapper.clickOption(0, 0, 'doubleClick'); + expect(wrapper.isOpen()).toBeFalsy(); }); - // https://github.com/ant-design/ant-design/issues/9084 - it('should trigger loadData when expandTrigger is hover', () => { - const options = [ - { - value: 'zhejiang', - label: 'Zhejiang', - isLeaf: false, - }, - { - value: 'jiangsu', - label: 'Jiangsu', - isLeaf: false, - }, - ]; - const loadData = jest.fn(); - const wrapper = mount( - - - , - ); - wrapper.find('input').simulate('click'); - const menus = wrapper.find('.rc-cascader-menu'); - const menu1Items = menus.at(0).find('.rc-cascader-menu-item'); - menu1Items.at(0).simulate('mouseEnter'); - jest.runAllTimers(); - expect(loadData).toHaveBeenCalled(); + describe('loadData', () => { + it('basic load', () => { + const loadData = jest.fn(); + const wrapper = mount( + } + options={[ + { + label: 'Bamboo', + value: 'bamboo', + isLeaf: false, + }, + ]} + loadData={loadData} + open + />, + ); + + wrapper.find('.rc-cascader-menu-item-content').first().simulate('click'); + expect(wrapper.exists('.loading-icon')).toBeTruthy(); + expect(loadData).toHaveBeenCalledWith([ + expect.objectContaining({ + value: 'bamboo', + }), + ]); + + // Fill data + wrapper.setProps({ + options: [ + { + label: 'Bamboo', + value: 'bamboo', + isLeaf: false, + children: [], + }, + ], + }); + wrapper.update(); + expect(wrapper.exists('.loading-icon')).toBeFalsy(); + }); + + // https://github.com/ant-design/ant-design/issues/9084 + it('should trigger loadData when expandTrigger is hover', () => { + const options = [ + { + value: 'zhejiang', + label: 'Zhejiang', + isLeaf: false, + }, + { + value: 'jiangsu', + label: 'Jiangsu', + isLeaf: false, + }, + ]; + const loadData = jest.fn(); + const wrapper = mount( + + + , + ); + wrapper.find('input').simulate('click'); + const menus = wrapper.find('.rc-cascader-menu'); + const menu1Items = menus.at(0).find('.rc-cascader-menu-item'); + menu1Items.at(0).simulate('mouseEnter'); + jest.runAllTimers(); + expect(loadData).toHaveBeenCalled(); + }); }); // https://github.com/ant-design/ant-design/issues/9793 it('should not trigger onBlur and onFocus when select item', () => { - const onFocus = jest.fn(); - const onBlur = jest.fn(); - const wrapper = mount( - - - , - ); - wrapper.find('input').simulate('focus'); - wrapper.find('input').simulate('click'); - const menus = wrapper.find('.rc-cascader-menu'); - const menu1Items = menus.at(0).find('.rc-cascader-menu-item'); - menu1Items.at(0).simulate('mouseDown'); - menu1Items.at(0).simulate('click'); - jest.runAllTimers(); - expect(onFocus).toHaveBeenCalledTimes(1); - expect(onBlur).toHaveBeenCalledTimes(0); + // This function is handled by `rc-select` instead }); // https://github.com/ant-design/ant-design/issues/18713 @@ -621,7 +596,7 @@ describe('Cascader', () => { , ); const menus = wrapper.find('.rc-cascader-menu'); - expect(menus).toMatchSnapshot(); + expect(menus.render()).toMatchSnapshot(); }); it('should render custom dropdown correctly', () => { @@ -629,7 +604,7 @@ describe('Cascader', () => { ( + dropdownRender={menus => (
    {menus}
    @@ -640,60 +615,54 @@ describe('Cascader', () => { , ); + const customDropdown = wrapper.find('.custom-dropdown'); expect(customDropdown.length).toBe(1); const customDropdownContent = wrapper.find('.custom-dropdown-content'); expect(customDropdownContent.length).toBe(1); - const menus = wrapper.find('.rc-cascader-menus'); - expect(menus).toMatchSnapshot(); + const menus = wrapper.find('.rc-cascader-dropdown'); + expect(menus.render()).toMatchSnapshot(); }); - it('should display after select, when hidePopupOnSelect is false', () => { - const wrapper = mount( - - - , - ); - wrapper.find('input').simulate('click'); - let menus = wrapper.find('.rc-cascader-menu'); - expect(menus.length).toBe(1); - const menu1Items = menus.at(0).find('.rc-cascader-menu-item'); - expect(menu1Items.length).toBe(3); - menu1Items.at(2).simulate('click'); - expect( - wrapper.find('.rc-cascader-menu-item').at(2).hasClass('rc-cascader-menu-item-active'), - ).toBe(true); - - menus = wrapper.find('.rc-cascader-menu'); - expect(menus.length).toBe(2); - const menu2Items = menus.at(1).find('.rc-cascader-menu-item'); - expect(menu2Items.length).toBe(2); - - menu2Items.at(0).simulate('click'); - expect( - wrapper - .find('.rc-cascader-menu') - .at(1) - .find('.rc-cascader-menu-item') - .first() - .hasClass('rc-cascader-menu-item-active'), - ).toBe(true); - - expect(wrapper.state().popupVisible).toBeTruthy(); - fireEvent.mouseDown(document.body); - expect(wrapper.state().popupVisible).toBeFalsy(); - }); - - it('should toggle select panel when click it, even if hidePopupOnSelect is false', () => { - const wrapper = mount( - - - , - ); - expect(wrapper.state().popupVisible).toBeFalsy(); - wrapper.find('input').simulate('click'); - expect(wrapper.state().popupVisible).toBeTruthy(); - wrapper.find('input').simulate('click'); - expect(wrapper.state().popupVisible).toBeFalsy(); + describe('focus test', () => { + let domSpy; + let focusTimes = 0; + let blurTimes = 0; + + beforeAll(() => { + domSpy = spyElementPrototypes(HTMLElement, { + focus: () => { + focusTimes += 1; + }, + blur: () => { + blurTimes += 1; + }, + }); + }); + + beforeEach(() => { + focusTimes = 0; + blurTimes = 0; + }); + + afterAll(() => { + domSpy.mockRestore(); + }); + + it('focus', () => { + const cascaderRef = React.createRef(); + mount(); + + cascaderRef.current.focus(); + expect(focusTimes === 1).toBeTruthy(); + }); + + it('blur', () => { + const cascaderRef = React.createRef(); + mount(); + + cascaderRef.current.blur(); + expect(blurTimes === 1).toBeTruthy(); + }); }); }); diff --git a/tests/keyboard.spec.js b/tests/keyboard.spec.js index 6ecf7c3a..acf46e8f 100644 --- a/tests/keyboard.spec.js +++ b/tests/keyboard.spec.js @@ -4,7 +4,7 @@ import KeyCode from 'rc-util/lib/KeyCode'; import Cascader from '..'; import { addressOptions } from './demoOptions'; -describe('Cascader', () => { +describe('Cascader.Keyboard', () => { let wrapper; let selectedValue; let menus; @@ -13,11 +13,7 @@ describe('Cascader', () => { }; beforeEach(() => { - wrapper = mount( - - - , - ); + wrapper = mount(); }); afterEach(() => { @@ -25,138 +21,201 @@ describe('Cascader', () => { menus = null; }); - [['space', KeyCode.SPACE], ['enter', KeyCode.ENTER]].forEach(([name, keyCode]) => { + [ + // Space + ['space', KeyCode.SPACE], + // Enter + ['enter', KeyCode.ENTER], + ].forEach(([name, which]) => { it(`${name} to open`, () => { - wrapper.find('input').simulate('keyDown', { keyCode }); - expect(wrapper.find('.rc-cascader-menus-hidden').length).toBe(0); + wrapper.find('input').simulate('keyDown', { which }); + expect(wrapper.isOpen()).toBeTruthy(); - wrapper.find('input').simulate('keyDown', { keyCode: KeyCode.ESC }); - expect(wrapper.find('.rc-cascader-menus-hidden').length).toBe(1); + wrapper.find('input').simulate('keyDown', { which: KeyCode.ESC }); + expect(wrapper.isOpen()).toBeFalsy(); }); }); it('should have keyboard support', () => { - wrapper.find('input').simulate('keyDown', { keyCode: KeyCode.DOWN }); + wrapper.find('input').simulate('keyDown', { which: KeyCode.DOWN }); menus = wrapper.find('.rc-cascader-menu'); - expect(wrapper.find('.rc-cascader-menus-hidden').length).toBe(0); + expect(wrapper.isOpen()).toBeTruthy(); expect(menus.length).toBe(1); - wrapper.find('input').simulate('keyDown', { keyCode: KeyCode.DOWN }); + wrapper.find('input').simulate('keyDown', { which: KeyCode.DOWN }); menus = wrapper.find('.rc-cascader-menu'); expect(menus.length).toBe(2); - wrapper.find('input').simulate('keyDown', { keyCode: KeyCode.RIGHT }); + wrapper.find('input').simulate('keyDown', { which: KeyCode.RIGHT }); menus = wrapper.find('.rc-cascader-menu'); expect(menus.length).toBe(3); - wrapper.find('input').simulate('keyDown', { keyCode: KeyCode.RIGHT }); + wrapper.find('input').simulate('keyDown', { which: KeyCode.RIGHT }); menus = wrapper.find('.rc-cascader-menu'); expect(menus.length).toBe(3); - wrapper.find('input').simulate('keyDown', { keyCode: KeyCode.LEFT }); + wrapper.find('input').simulate('keyDown', { which: KeyCode.LEFT }); menus = wrapper.find('.rc-cascader-menu'); expect(menus.length).toBe(3); - wrapper.find('input').simulate('keyDown', { keyCode: KeyCode.QUESTION_MARK }); + wrapper.find('input').simulate('keyDown', { which: KeyCode.QUESTION_MARK }); menus = wrapper.find('.rc-cascader-menu'); expect(menus.length).toBe(3); - wrapper.find('input').simulate('keyDown', { keyCode: KeyCode.LEFT }); + wrapper.find('input').simulate('keyDown', { which: KeyCode.LEFT }); menus = wrapper.find('.rc-cascader-menu'); expect(menus.length).toBe(2); - expect( - wrapper - .find('.rc-cascader-menu-item-active') - .at(0) - .text(), - ).toBe(addressOptions[0].label); - wrapper.find('input').simulate('keyDown', { keyCode: KeyCode.DOWN }); + expect(wrapper.find('.rc-cascader-menu-item-active').at(0).text()).toBe( + addressOptions[0].label, + ); + wrapper.find('input').simulate('keyDown', { which: KeyCode.DOWN }); menus = wrapper.find('.rc-cascader-menu'); expect(menus.length).toBe(2); - expect( - wrapper - .find('.rc-cascader-menu-item-active') - .at(0) - .text(), - ).toBe(addressOptions[1].label); - wrapper.find('input').simulate('keyDown', { keyCode: KeyCode.RIGHT }); - wrapper.find('input').simulate('keyDown', { keyCode: KeyCode.RIGHT }); - wrapper.find('input').simulate('keyDown', { keyCode: KeyCode.ENTER }); - expect( - wrapper - .find('.rc-cascader-menus') - .hostNodes() - .hasClass('rc-cascader-menus-hidden'), - ).toBe(true); + expect(wrapper.find('.rc-cascader-menu-item-active').at(0).text()).toBe( + addressOptions[1].label, + ); + wrapper.find('input').simulate('keyDown', { which: KeyCode.RIGHT }); + wrapper.find('input').simulate('keyDown', { which: KeyCode.RIGHT }); + wrapper.find('input').simulate('keyDown', { which: KeyCode.ENTER }); + expect(wrapper.isOpen()).toBeFalsy(); expect(selectedValue).toEqual(['zj', 'hangzhou', 'yuhang']); }); - it('should have close menu when press some keys', () => { - wrapper.find('input').simulate('keyDown', { keyCode: KeyCode.DOWN }); - expect( - wrapper - .find('.rc-cascader-menus') - .hostNodes() - .hasClass('rc-cascader-menus-hidden'), - ).toBe(false); - wrapper.find('input').simulate('keyDown', { keyCode: KeyCode.LEFT }); - expect( - wrapper - .find('.rc-cascader-menus') - .hostNodes() - .hasClass('rc-cascader-menus-hidden'), - ).toBe(true); - wrapper.find('input').simulate('keyDown', { keyCode: KeyCode.DOWN }); - expect( - wrapper - .find('.rc-cascader-menus') - .hostNodes() - .hasClass('rc-cascader-menus-hidden'), - ).toBe(false); - wrapper.find('input').simulate('keyDown', { keyCode: KeyCode.BACKSPACE }); + it('rtl', () => { + wrapper = mount(); + wrapper.find('input').simulate('keyDown', { which: KeyCode.DOWN }); + expect(wrapper.isOpen()).toBeTruthy(); + + wrapper.find('input').simulate('keyDown', { which: KeyCode.DOWN }); expect( - wrapper - .find('.rc-cascader-menus') - .hostNodes() - .hasClass('rc-cascader-menus-hidden'), - ).toBe(true); - wrapper.find('input').simulate('keyDown', { keyCode: KeyCode.DOWN }); + wrapper.find('.rc-cascader-menu-item-active .rc-cascader-menu-item-content').last().text(), + ).toEqual('福建'); + + wrapper.find('input').simulate('keyDown', { which: KeyCode.LEFT }); expect( - wrapper - .find('.rc-cascader-menus') - .hostNodes() - .hasClass('rc-cascader-menus-hidden'), - ).toBe(false); - wrapper.find('input').simulate('keyDown', { keyCode: KeyCode.RIGHT }); - wrapper.find('input').simulate('keyDown', { keyCode: KeyCode.ESC }); + wrapper.find('.rc-cascader-menu-item-active .rc-cascader-menu-item-content').last().text(), + ).toEqual('福州'); + + wrapper.find('input').simulate('keyDown', { which: KeyCode.RIGHT }); expect( - wrapper - .find('.rc-cascader-menus') - .hostNodes() - .hasClass('rc-cascader-menus-hidden'), - ).toBe(true); + wrapper.find('.rc-cascader-menu-item-active .rc-cascader-menu-item-content').last().text(), + ).toEqual('福建'); + + wrapper.find('input').simulate('keyDown', { which: KeyCode.RIGHT }); + expect(wrapper.isOpen()).toBeFalsy(); + }); + + describe('up', () => { + it('Select last enabled', () => { + wrapper.find('input').simulate('keyDown', { which: KeyCode.ENTER }); + expect(wrapper.isOpen()).toBeTruthy(); + + wrapper.find('input').simulate('keyDown', { which: KeyCode.UP }); + expect( + wrapper.find('.rc-cascader-menu-item-active .rc-cascader-menu-item-content').last().text(), + ).toEqual('北京'); + }); + + it('ignore disabled item', () => { + wrapper = mount( + , + ); + + wrapper.find('input').simulate('keyDown', { which: KeyCode.ENTER }); + wrapper.find('input').simulate('keyDown', { which: KeyCode.UP }); + expect( + wrapper.find('.rc-cascader-menu-item-active .rc-cascader-menu-item-content').last().text(), + ).toEqual('Little'); + }); + }); + + it('should have close menu when press some keys', () => { + wrapper.find('input').simulate('keyDown', { which: KeyCode.DOWN }); + expect(wrapper.isOpen()).toBeTruthy(); + wrapper.find('input').simulate('keyDown', { which: KeyCode.LEFT }); + expect(wrapper.isOpen()).toBeFalsy(); + wrapper.find('input').simulate('keyDown', { which: KeyCode.DOWN }); + expect(wrapper.isOpen()).toBeTruthy(); + wrapper.find('input').simulate('keyDown', { which: KeyCode.BACKSPACE }); + expect(wrapper.isOpen()).toBeFalsy(); + wrapper.find('input').simulate('keyDown', { which: KeyCode.DOWN }); + expect(wrapper.isOpen()).toBeTruthy(); + wrapper.find('input').simulate('keyDown', { which: KeyCode.RIGHT }); + wrapper.find('input').simulate('keyDown', { which: KeyCode.ESC }); + expect(wrapper.isOpen()).toBeFalsy(); }); it('should call the Cascader onKeyDown callback in all cases', () => { const onKeyDown = jest.fn(); wrapper = mount( - - - , + , ); - wrapper.find('input').simulate('keyDown', { keyCode: KeyCode.DOWN }); - expect(wrapper.state().popupVisible).toBeTruthy(); - wrapper.find('input').simulate('keyDown', { keyCode: KeyCode.ESC }); - expect(wrapper.state().popupVisible).toBeFalsy(); - wrapper.find('input').simulate('keyDown', { keyCode: KeyCode.ENTER }); + wrapper.find('input').simulate('keyDown', { which: KeyCode.DOWN }); + expect(wrapper.isOpen()).toBeTruthy(); + wrapper.find('input').simulate('keyDown', { which: KeyCode.ESC }); + expect(wrapper.isOpen()).toBeFalsy(); + wrapper.find('input').simulate('keyDown', { which: KeyCode.ENTER }); expect(onKeyDown).toHaveBeenCalledTimes(3); }); - it('should not handle keyDown events when children specify the onKeyDown', () => { + it('changeOnSelect', () => { + wrapper = mount(); + wrapper.find('input').simulate('keyDown', { which: KeyCode.ENTER }); + expect(wrapper.isOpen()).toBeTruthy(); + + // 0-0 + wrapper.find('input').simulate('keyDown', { which: KeyCode.DOWN }); + + // 0-0-0 + wrapper.find('input').simulate('keyDown', { which: KeyCode.RIGHT }); + + // Select + wrapper.find('input').simulate('keyDown', { which: KeyCode.ENTER }); + expect(wrapper.isOpen()).toBeFalsy(); + expect(selectedValue).toEqual(['fj', 'fuzhou']); + }); + + it('all disabled should not crash', () => { + wrapper = mount( + ({ ...opt, disabled: true }))} + onChange={onChange} + changeOnSelect + />, + ); + for (let i = 0; i < 10; i += 1) { + wrapper.find('input').simulate('keyDown', { which: KeyCode.DOWN }); + } + expect( + wrapper.find('.rc-cascader-menu-item-active .rc-cascader-menu-item-content'), + ).toHaveLength(0); + }); + + // TODO: This is strange that we need check on this + it.skip('should not handle keyDown events when children specify the onKeyDown', () => { wrapper = mount( {}} /> , ); - wrapper.find('input').simulate('keyDown', { keyCode: KeyCode.DOWN }); - menus = wrapper.find('.rc-cascader-menu'); - expect(wrapper.find('.rc-cascader-menus-hidden').length).toBe(0); - expect(menus.length).toBe(0); + + wrapper.find('input').simulate('keyDown', { which: KeyCode.DOWN }); + expect(wrapper.isOpen()).toBeFalsy(); }); }); diff --git a/tests/search.spec.tsx b/tests/search.spec.tsx new file mode 100644 index 00000000..f0cf734d --- /dev/null +++ b/tests/search.spec.tsx @@ -0,0 +1,134 @@ +/* eslint-disable @typescript-eslint/consistent-type-imports */ + +import React from 'react'; +import { mount, ReactWrapper } from './enzyme'; +import Cascader from '../src'; + +describe('Cascader.Search', () => { + function doSearch(wrapper: ReactWrapper, search: string) { + wrapper.find('input').simulate('change', { + target: { + value: search, + }, + }); + } + + const options = [ + { + label: 'Label Light', + value: 'light', + }, + { + label: 'Label Bamboo', + value: 'bamboo', + children: [ + { + label: 'Label Little', + value: 'little', + children: [ + { + label: 'Toy Fish', + value: 'fish', + }, + { + label: 'Toy Cards', + value: 'cards', + }, + ], + }, + ], + }, + ] as any; + + it('default search', () => { + const wrapper = mount(); + + // Leaf + doSearch(wrapper, 'toy'); + let itemList = wrapper.find('div.rc-cascader-menu-item-content'); + expect(itemList).toHaveLength(2); + expect(itemList.at(0).text()).toEqual('Label Bamboo / Label Little / Toy Fish'); + expect(itemList.at(1).text()).toEqual('Label Bamboo / Label Little / Toy Cards'); + + // Parent + doSearch(wrapper, 'Label Little'); + itemList = wrapper.find('div.rc-cascader-menu-item-content'); + expect(itemList).toHaveLength(2); + expect(itemList.at(0).text()).toEqual('Label Bamboo / Label Little / Toy Fish'); + expect(itemList.at(1).text()).toEqual('Label Bamboo / Label Little / Toy Cards'); + }); + + it('changeOnSelect', () => { + const wrapper = mount(); + + // Leaf + doSearch(wrapper, 'Label Little'); + const itemList = wrapper.find('div.rc-cascader-menu-item-content'); + expect(itemList).toHaveLength(3); + expect(itemList.at(0).text()).toEqual('Label Bamboo / Label Little'); + expect(itemList.at(1).text()).toEqual('Label Bamboo / Label Little / Toy Fish'); + expect(itemList.at(2).text()).toEqual('Label Bamboo / Label Little / Toy Cards'); + }); + + it('sort', () => { + const wrapper = mount( + { + const finalA = pathA[pathA.length - 1]; + const finalB = pathB[pathB.length - 1]; + + if (finalA.value < finalB.value) { + return -1; + } + return 1; + }, + }} + />, + ); + + doSearch(wrapper, 'toy'); + const itemList = wrapper.find('div.rc-cascader-menu-item-content'); + expect(itemList).toHaveLength(2); + expect(itemList.at(0).text()).toEqual('Label Bamboo / Label Little / Toy Cards'); + expect(itemList.at(1).text()).toEqual('Label Bamboo / Label Little / Toy Fish'); + }); + + it('limit', () => { + const wrapper = mount( + , + ); + + doSearch(wrapper, 'toy'); + const itemList = wrapper.find('div.rc-cascader-menu-item-content'); + expect(itemList).toHaveLength(1); + expect(itemList.at(0).text()).toEqual('Label Bamboo / Label Little / Toy Fish'); + }); + + it('render', () => { + const wrapper = mount( + + `${prefixCls}-${inputValue}-${optList.map(opt => opt.value).join('~')}`, + }} + />, + ); + + doSearch(wrapper, 'toy'); + const itemList = wrapper.find('div.rc-cascader-menu-item-content'); + expect(itemList).toHaveLength(2); + expect(itemList.at(0).text()).toEqual('rc-cascader-toy-bamboo~little~fish'); + expect(itemList.at(1).text()).toEqual('rc-cascader-toy-bamboo~little~cards'); + }); +}); diff --git a/tests/setup.js b/tests/setup.js index 684b8a6f..7e01e9c0 100644 --- a/tests/setup.js +++ b/tests/setup.js @@ -1,8 +1,24 @@ -global.requestAnimationFrame = global.requestAnimationFrame || function requestAnimationFrame(cb) { - return setTimeout(cb, 0); -}; +global.requestAnimationFrame = + global.requestAnimationFrame || + function requestAnimationFrame(cb) { + return setTimeout(cb, 0); + }; const Enzyme = require('enzyme'); const Adapter = require('enzyme-adapter-react-16'); Enzyme.configure({ adapter: new Adapter() }); + +Object.assign(Enzyme.ReactWrapper.prototype, { + isOpen() { + return !!this.find('Trigger').props().popupVisible; + }, + clickOption(menuIndex, itemIndex, type = 'click') { + const menu = this.find('ul.rc-cascader-menu').at(menuIndex); + const itemList = menu.find('li.rc-cascader-menu-item'); + + itemList.at(itemIndex).find('div').first().simulate(type); + + return this; + }, +});