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(
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;
+ },
+});