@@ -86,9 +82,8 @@ class Test extends React.Component {
value={value}
animation={useAnim ? 'slide-up' : null}
choiceTransitionName="rc-select-selection__choice-zoom"
- dropdownMenuStyle={dropdownMenuStyle}
style={{ width: 500 }}
- multiple
+ mode="multiple"
loading={loading}
showArrow={showArrow}
allowClear
@@ -111,3 +106,4 @@ class Test extends React.Component {
}
export default Test;
+/* eslint-enable */
diff --git a/examples/optgroup.js b/examples/optgroup.tsx
similarity index 95%
rename from examples/optgroup.js
rename to examples/optgroup.tsx
index 63cb272c7..8a96c9a44 100644
--- a/examples/optgroup.js
+++ b/examples/optgroup.tsx
@@ -1,5 +1,4 @@
-/* eslint no-console: 0 */
-
+/* eslint-disable no-console */
import React from 'react';
import Select, { Option, OptGroup } from '../src';
import '../assets/index.less';
@@ -43,3 +42,4 @@ const Test = () => (
);
export default Test;
+/* eslint-enable */
diff --git a/examples/optionFilterProp.js b/examples/optionFilterProp.js
deleted file mode 100644
index 0d0df1f66..000000000
--- a/examples/optionFilterProp.js
+++ /dev/null
@@ -1,36 +0,0 @@
-/* eslint no-console: 0 */
-
-import React from 'react';
-import Select, { Option } from '../src';
-import '../assets/index.less';
-
-function onChange(value) {
- console.log(`selected ${value}`);
-}
-
-const Test = () => (
-
-);
-
-export default Test;
diff --git a/examples/optionFilterProp.tsx b/examples/optionFilterProp.tsx
new file mode 100644
index 000000000..48e183af5
--- /dev/null
+++ b/examples/optionFilterProp.tsx
@@ -0,0 +1,39 @@
+import * as React from 'react';
+import Select, { Option } from '../src';
+import '../assets/index.less';
+
+const Test = () => {
+ const [value, setValue] = React.useState
('');
+
+ return (
+
+
Select optionFilterProp
+
+
+
+ {value}
+
+ );
+};
+
+export default Test;
diff --git a/examples/optionLabelProp.js b/examples/optionLabelProp.js
deleted file mode 100644
index 1bd2f22b6..000000000
--- a/examples/optionLabelProp.js
+++ /dev/null
@@ -1,28 +0,0 @@
-/* eslint no-console: 0 */
-
-import React from 'react';
-import Select, { Option } from '../src';
-import '../assets/index.less';
-
-function Test() {
- const cases = {
- 0: { name: 'Case 1' },
- 1: { name: 'Case 2' },
- 2: { name: 'Case 3' },
- };
-
- return (
-
-
Select optionLabelProp
-
-
- );
-}
-
-export default Test;
diff --git a/examples/optionLabelProp.tsx b/examples/optionLabelProp.tsx
new file mode 100644
index 000000000..ae30c6673
--- /dev/null
+++ b/examples/optionLabelProp.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import Select from '../src';
+import '../assets/index.less';
+
+const data: { value: number; label: string; displayLabel: string }[] = [];
+for (let i = 0; i < 10; i += 1) {
+ data.push({
+ value: i,
+ label: `label ${i}`,
+ displayLabel: `display ${i}`,
+ });
+}
+
+function Test() {
+ return (
+
+
Select optionLabelProp
+
+
+ );
+}
+
+export default Test;
diff --git a/examples/single-animation.js b/examples/single-animation.tsx
similarity index 94%
rename from examples/single-animation.js
rename to examples/single-animation.tsx
index b5a32f4f2..7dadddf48 100644
--- a/examples/single-animation.js
+++ b/examples/single-animation.tsx
@@ -1,5 +1,4 @@
-/* eslint no-console: 0 */
-
+/* eslint-disable no-console */
import React from 'react';
import Select, { Option } from '../src';
import '../assets/index.less';
@@ -43,3 +42,4 @@ const Test = () => (
);
export default Test;
+/* eslint-enable */
diff --git a/examples/single.js b/examples/single.tsx
similarity index 79%
rename from examples/single.js
rename to examples/single.tsx
index 3dd595abf..8ab69463c 100644
--- a/examples/single.js
+++ b/examples/single.tsx
@@ -1,5 +1,4 @@
-/* eslint no-console: 0 */
-
+/* eslint-disable no-console */
import React from 'react';
import Select, { Option } from '../src';
import '../assets/index.less';
@@ -7,13 +6,13 @@ import '../assets/index.less';
class Test extends React.Component {
state = {
destroy: false,
- value: 9,
+ value: '9',
};
onChange = e => {
let value;
if (e && e.target) {
- value = e.target.value;
+ ({ value } = e.target);
} else {
value = e;
}
@@ -46,6 +45,7 @@ class Test extends React.Component {
if (destroy) {
return null;
}
+
return (
@@ -57,17 +57,18 @@ class Test extends React.Component {
id="my-select"
value={value}
placeholder="placeholder"
- dropdownMenuStyle={{ maxHeight: 200 }}
+ showSearch
style={{ width: 500 }}
onBlur={this.onBlur}
onFocus={this.onFocus}
onSearch={this.onSearch}
allowClear
- optionLabelProp="children"
optionFilterProp="text"
onChange={this.onChange}
- firstActiveValue="2"
backfill
+ onPopupScroll={(...args) => {
+ console.log('Scroll:', args);
+ }}
>
- {[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(i => {
- return (
-
- );
- })}
+ {[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(i => (
+
+ ))}
native select
@@ -104,13 +103,11 @@ class Test extends React.Component {
disabled
- {[1, 2, 3, 4, 5, 6, 7, 8, 9].map(i => {
- return (
-
- );
- })}
+ {[1, 2, 3, 4, 5, 6, 7, 8, 9].map(i => (
+
+ ))}
@@ -124,3 +121,4 @@ class Test extends React.Component {
}
export default Test;
+/* eslint-enable */
diff --git a/examples/suggest.js b/examples/suggest.tsx
similarity index 85%
rename from examples/suggest.js
rename to examples/suggest.tsx
index 84e94c763..6ea142324 100644
--- a/examples/suggest.js
+++ b/examples/suggest.tsx
@@ -1,12 +1,11 @@
-/* eslint no-console: 0 */
-
+/* eslint-disable no-console */
import React from 'react';
import Select, { Option } from '../src';
import '../assets/index.less';
import { fetch } from './common/tbFetchSuggest';
-const Input = props => ;
+const Input = React.forwardRef((props, ref) => );
class Test extends React.Component {
state = {
@@ -45,9 +44,7 @@ class Test extends React.Component {
render() {
const { data, value } = this.state;
- const options = data.map(d => {
- return ;
- });
+ const options = data.map(d => );
return (
suggest
@@ -55,7 +52,7 @@ class Test extends React.Component {
@@ -66,3 +69,4 @@ class Test extends React.Component {
}
export default Test;
+/* eslint-enable */
diff --git a/jest.config.js b/jest.config.js
new file mode 100644
index 000000000..66a2c8753
--- /dev/null
+++ b/jest.config.js
@@ -0,0 +1,3 @@
+module.exports = {
+ snapshotSerializers: [require.resolve('enzyme-to-json/serializer')],
+};
diff --git a/now.json b/now.json
index d15931762..1a2d15cf4 100644
--- a/now.json
+++ b/now.json
@@ -5,7 +5,7 @@
{
"src": "package.json",
"use": "@now/static-build",
- "config": { "distDir": "build" }
+ "config": { "distDir": ".doc" }
}
]
}
diff --git a/package.json b/package.json
index d3c09a98a..962831824 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,7 @@
"module": "./es/index",
"files": [
"assets/*.css",
+ "assets/*.less",
"es",
"lib"
],
@@ -24,69 +25,39 @@
"url": "http://github.com/react-component/select/issues"
},
"license": "MIT",
- "config": {
- "port": 8003
- },
"scripts": {
- "build": "rc-tools run build",
- "compile": "rc-tools run compile --babel-runtime",
- "gh-pages": "rc-tools run gh-pages",
- "start": "rc-tools run storybook",
- "pub": "rc-tools run pub --babel-runtime",
- "lint": "rc-tools run lint",
- "lint:fix": "rc-tools run lint --fix",
- "prettier": "rc-tools run prettier",
- "test": "rc-tools run test",
- "prepublish": "rc-tools run guard",
- "init-tslint": "rc-tools run gen-lint-config",
- "coverage": "rc-tools run test --coverage",
- "pre-commit": "rc-tools run pre-commit",
- "lint-staged": "lint-staged",
+ "start": "cross-env NODE_ENV=development father doc dev --storybook",
+ "build": "father doc build --storybook",
+ "compile": "father build",
+ "prepublishOnly": "npm run compile && np --no-cleanup --yolo --no-publish",
+ "lint": "eslint src/ examples/ --ext .tsx,.ts,.jsx,.js",
+ "test": "father test",
"now-build": "npm run build"
},
- "devDependencies": {
- "@types/classnames": "^2.2.6",
- "@types/enzyme": "^3.1.15",
- "@types/raf": "^3.4.0",
- "@types/react": "^16.7.17",
- "@types/react-dom": "^16.0.11",
- "@types/warning": "^3.0.0",
- "enzyme": "^3.8.0",
- "enzyme-adapter-react-16": "^1.7.1",
- "enzyme-to-json": "^3.1.4",
- "jsonp": "^0.2.0",
- "lint-staged": "^9.0.0",
- "pre-commit": "1.x",
- "querystring": "^0.2.0",
- "rc-dialog": "7.5.5",
- "rc-test": "^6.0.1",
- "rc-tools": "^9.6.0",
- "react": "^16.0.0",
- "react-dom": "^16.0.0",
- "react-test-renderer": "^16.0.0",
- "typescript": "^3.2.2"
+ "peerDependencies": {
+ "react": "*",
+ "react-dom": "*"
},
"dependencies": {
- "babel-runtime": "^6.23.0",
"classnames": "2.x",
- "component-classes": "1.x",
- "dom-scroll-into-view": "1.x",
- "prop-types": "^15.5.8",
- "raf": "^3.4.0",
- "rc-animate": "2.x",
- "rc-menu": "^7.3.0",
- "rc-trigger": "^2.5.4",
- "rc-util": "^4.0.4",
- "react-lifecycles-compat": "^3.0.2",
- "warning": "^4.0.2"
+ "rc-animate": "^2.10.0",
+ "rc-trigger": "^2.6.5",
+ "rc-util": "^4.11.0",
+ "rc-virtual-list": "^0.0.0-alpha.23",
+ "warning": "^4.0.3"
},
- "pre-commit": [
- "lint-staged"
- ],
- "lint-staged": {
- "*.{js,jsx,ts,tsx}": [
- "npm run pre-commit",
- "git add"
- ]
+ "devDependencies": {
+ "@types/jest": "^24.0.18",
+ "@types/react": "^16.8.19",
+ "@types/react-dom": "^16.8.4",
+ "@types/warning": "^3.0.0",
+ "cross-env": "^5.2.0",
+ "enzyme": "^3.3.0",
+ "enzyme-to-json": "^3.4.0",
+ "father": "^2.13.2",
+ "jsonp": "^0.2.1",
+ "np": "^5.0.3",
+ "rc-dialog": "^7.5.5",
+ "typescript": "^3.5.2"
}
}
diff --git a/src/DropdownMenu.tsx b/src/DropdownMenu.tsx
deleted file mode 100644
index 12c0cfe25..000000000
--- a/src/DropdownMenu.tsx
+++ /dev/null
@@ -1,246 +0,0 @@
-import scrollIntoView from 'dom-scroll-into-view';
-import * as PropTypes from 'prop-types';
-import raf from 'raf';
-import Menu from 'rc-menu';
-import toArray from 'rc-util/lib/Children/toArray';
-import * as React from 'react';
-import { findDOMNode } from 'react-dom';
-import { renderSelect, valueType } from './PropTypes';
-import { getSelectKeys, preventDefaultEvent, saveRef } from './util';
-
-export interface IMenuEvent {
- key: string;
- item: React.ReactNode;
- domEvent: Event;
- selectedKeys: string[];
-}
-
-export interface IDropdownMenuProps {
- ariaId: string;
- defaultActiveFirstOption: boolean;
- value: valueType;
- dropdownMenuStyle: React.CSSProperties;
- multiple: boolean;
- onPopupFocus: React.FocusEventHandler
;
- onPopupScroll: React.UIEventHandler;
- onMenuDeselect: (e: { item: any; domEvent: KeyboardEvent }) => void;
- onMenuSelect: (e: { item: any; domEvent: KeyboardEvent }) => void;
- prefixCls: string;
- menuItems: JSX.Element[];
- inputValue: string | string[];
- visible: boolean;
- firstActiveValue: valueType;
- menuItemSelectedIcon: renderSelect;
- backfillValue: string;
-}
-
-export default class DropdownMenu extends React.Component> {
- public static displayName = 'DropdownMenu';
- public static propTypes = {
- ariaId: PropTypes.string,
- defaultActiveFirstOption: PropTypes.bool,
- value: PropTypes.any,
- dropdownMenuStyle: PropTypes.object,
- multiple: PropTypes.bool,
- onPopupFocus: PropTypes.func,
- onPopupScroll: PropTypes.func,
- onMenuDeSelect: PropTypes.func,
- onMenuSelect: PropTypes.func,
- prefixCls: PropTypes.string,
- menuItems: PropTypes.any,
- inputValue: PropTypes.string,
- visible: PropTypes.bool,
- firstActiveValue: PropTypes.string,
- menuItemSelectedIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.node]),
- };
- public rafInstance: number | null = null;
- public lastInputValue: string | string[] | undefined;
- public saveMenuRef: any;
- public menuRef: any;
- public lastVisible: boolean = false;
- public firstActiveItem: any;
- constructor(props: Partial) {
- super(props);
- this.lastInputValue = props.inputValue;
- this.saveMenuRef = saveRef(this, 'menuRef');
- }
-
- public componentDidMount() {
- this.scrollActiveItemToView();
- this.lastVisible = this.props.visible as boolean;
- }
-
- public shouldComponentUpdate(nextProps: Partial) {
- if (!nextProps.visible) {
- this.lastVisible = false;
- }
- // freeze when hide
- return (
- (this.props.visible && !nextProps.visible) ||
- nextProps.visible ||
- nextProps.inputValue !== this.props.inputValue
- );
- }
-
- public componentDidUpdate(prevProps: Partial) {
- const props = this.props;
- if (!prevProps.visible && props.visible) {
- this.scrollActiveItemToView();
- }
- this.lastVisible = props.visible as boolean;
- this.lastInputValue = props.inputValue as string;
- }
-
- public componentWillUnmount() {
- if (this.rafInstance) {
- raf.cancel(this.rafInstance);
- }
- }
-
- public scrollActiveItemToView = () => {
- // scroll into view
- const itemComponent = findDOMNode(this.firstActiveItem);
- const { visible, firstActiveValue } = this.props;
- const value = this.props.value as string[];
- if (!itemComponent || !visible) {
- return;
- }
- const scrollIntoViewOpts: {
- alignWithTop?: boolean;
- onlyScrollIfNeeded?: boolean;
- } = {
- onlyScrollIfNeeded: true,
- };
- if ((!value || value.length === 0) && firstActiveValue) {
- scrollIntoViewOpts.alignWithTop = true;
- }
-
- // Delay to scroll since current frame item position is not ready when pre view is by filter
- // https://github.com/ant-design/ant-design/issues/11268#issuecomment-406634462
- this.rafInstance = raf(() => {
- scrollIntoView(itemComponent, findDOMNode(this.menuRef), scrollIntoViewOpts);
- });
- };
-
- public renderMenu = () => {
- const {
- menuItems,
- menuItemSelectedIcon,
- defaultActiveFirstOption,
- prefixCls,
- multiple,
- onMenuSelect,
- inputValue,
- backfillValue,
- onMenuDeselect,
- visible,
- } = this.props;
- const firstActiveValue = this.props.firstActiveValue as string;
-
- if (menuItems && menuItems.length) {
- const menuProps: Partial<{
- onDeselect: (e: { item: any; domEvent: KeyboardEvent }) => void;
- onSelect: (e: { item: any; domEvent: KeyboardEvent }) => void;
- onClick: (e: { item: any; domEvent: KeyboardEvent }) => void;
- }> = {};
- if (multiple) {
- menuProps.onDeselect = onMenuDeselect;
- menuProps.onSelect = onMenuSelect;
- } else {
- menuProps.onClick = onMenuSelect;
- }
- const value = this.props.value as string;
- const selectedKeys = getSelectKeys(menuItems, value) as string[];
- const activeKeyProps: {
- activeKey?: string;
- } = {};
-
- let defaultActiveFirst: boolean | undefined = defaultActiveFirstOption;
- let clonedMenuItems = menuItems;
- if (selectedKeys.length || firstActiveValue) {
- if (visible && !this.lastVisible) {
- activeKeyProps.activeKey = selectedKeys[0] || firstActiveValue;
- } else if (!visible) {
- // Do not trigger auto active since we already have selectedKeys
- if (selectedKeys[0]) {
- defaultActiveFirst = false;
- }
- activeKeyProps.activeKey = undefined;
- }
- let foundFirst = false;
- // set firstActiveItem via cloning menus
- // for scroll into view
- const clone = (item: any) => {
- const key = item.key as string;
- if (
- (!foundFirst && selectedKeys.indexOf(key) !== -1) ||
- (!foundFirst && !selectedKeys.length && firstActiveValue.indexOf(item.key) !== -1)
- ) {
- foundFirst = true;
- return React.cloneElement(item, {
- ref: (ref: HTMLDivElement) => {
- this.firstActiveItem = ref;
- },
- });
- }
- return item;
- };
-
- clonedMenuItems = menuItems.map((item: any) => {
- if (item.type.isMenuItemGroup) {
- const children = toArray(item.props.children).map(clone);
- return React.cloneElement(item, {}, children);
- }
- return clone(item);
- });
- } else {
- // Clear firstActiveItem when dropdown menu items was empty
- // Avoid `Unable to find node on an unmounted component`
- // https://github.com/ant-design/ant-design/issues/10774
- this.firstActiveItem = null;
- }
-
- // clear activeKey when inputValue change
- const lastValue = value && value[value.length - 1];
- if (inputValue !== this.lastInputValue && (!lastValue || lastValue !== backfillValue)) {
- activeKeyProps.activeKey = '';
- }
-
- return (
-
- );
- }
- return null;
- };
-
- public render() {
- const renderMenu = this.renderMenu();
- return renderMenu ? (
-
- {renderMenu}
-
- ) : null;
- }
-}
diff --git a/src/OptGroup.tsx b/src/OptGroup.tsx
index ae9c7a080..073589b64 100644
--- a/src/OptGroup.tsx
+++ b/src/OptGroup.tsx
@@ -1,12 +1,18 @@
-import { Component } from 'react';
-
-export interface IOptGroupProps {
- label: string;
- value: string | number;
- key: string | number;
- // Everything for testing
- testprop?: any;
+/* istanbul ignore file */
+import * as React from 'react';
+import { OptionGroupData } from './interface';
+
+export interface OptGroupProps extends Omit {
+ children?: React.ReactNode;
}
-export default class OptGroup extends Component> {
- public static isSelectOptGroup = true;
+
+interface OptionGroupFC extends React.FC {
+ /** Legacy for check if is a Option Group */
+ isSelectOptGroup: boolean;
}
+
+/** This is a placeholder, not real render in dom */
+const OptGroup: OptionGroupFC = () => null;
+OptGroup.isSelectOptGroup = true;
+
+export default OptGroup;
diff --git a/src/Option.tsx b/src/Option.tsx
index 77ac2a06c..f454f38d2 100644
--- a/src/Option.tsx
+++ b/src/Option.tsx
@@ -1,20 +1,18 @@
-import * as PropTypes from 'prop-types';
-import { Component } from 'react';
+/* istanbul ignore file */
+import * as React from 'react';
+import { OptionData } from './interface';
-export interface IOptProps {
- title: string | number;
- label: string | number;
- value: string | number;
- key: string | number;
- className: string;
- disabled: boolean;
- // Everything for testing
- testprop?: any;
+export interface OptionProps extends Omit {
+ children: React.ReactNode;
}
-export default class Option extends Component> {
- public static propTypes = {
- value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
- };
- public static isSelectOption = true;
+interface OptionFC extends React.FC {
+ /** Legacy for check if is a Option Group */
+ isSelectOption: boolean;
}
+
+/** This is a placeholder, not real render in dom */
+const Option: OptionFC = () => null;
+Option.isSelectOption = true;
+
+export default Option;
diff --git a/src/OptionList.tsx b/src/OptionList.tsx
new file mode 100644
index 000000000..3958f5fe9
--- /dev/null
+++ b/src/OptionList.tsx
@@ -0,0 +1,301 @@
+import * as React from 'react';
+import KeyCode from 'rc-util/lib/KeyCode';
+import classNames from 'classnames';
+import List from 'rc-virtual-list';
+import TransBtn from './TransBtn';
+import {
+ OptionsType as SelectOptionsType,
+ FlattenOptionData as SelectFlattenOptionData,
+ OptionData,
+ RenderNode,
+} from './interface';
+import { RawValueType, FlattenOptionsType } from './interface/generator';
+
+// TODO: Not use virtual list if options count is less than a certain number
+
+export interface OptionListProps {
+ prefixCls: string;
+ id: string;
+ options: OptionsType;
+ flattenOptions: FlattenOptionsType;
+ height: number;
+ itemHeight: number;
+ values: Set;
+ multiple: boolean;
+ open: boolean;
+ defaultActiveFirstOption?: boolean;
+ notFoundContent?: React.ReactNode;
+ menuItemSelectedIcon?: RenderNode;
+ childrenAsData: boolean;
+ searchValue: string;
+
+ onSelect: (value: RawValueType, option: { selected: boolean }) => void;
+ onToggleOpen: (open?: boolean) => void;
+ /** Tell Select that some value is now active to make accessibility work */
+ onActiveValue: (value: RawValueType, index: number) => void;
+ onScroll: React.UIEventHandler;
+}
+
+export interface RefOptionListProps {
+ onKeyDown: React.KeyboardEventHandler;
+ onKeyUp: React.KeyboardEventHandler;
+}
+
+/**
+ * Using virtual list of option display.
+ * Will fallback to dom if use customize render.
+ */
+const OptionList: React.RefForwardingComponent<
+ RefOptionListProps,
+ OptionListProps
+> = (
+ {
+ prefixCls,
+ id,
+ flattenOptions,
+ childrenAsData,
+ values,
+ searchValue,
+ multiple,
+ defaultActiveFirstOption,
+ height,
+ itemHeight,
+ notFoundContent,
+ open,
+ menuItemSelectedIcon,
+ onSelect,
+ onToggleOpen,
+ onActiveValue,
+ onScroll,
+ },
+ ref,
+) => {
+ const itemPrefixCls = `${prefixCls}-item`;
+
+ // =========================== List ===========================
+ const listRef = React.useRef(null);
+
+ const onListMouseDown: React.MouseEventHandler = event => {
+ event.preventDefault();
+ };
+
+ const scrollIntoView = (index: number) => {
+ if (listRef.current) {
+ listRef.current.scrollTo({ index });
+ }
+ };
+
+ // ========================== Active ==========================
+ const getEnabledActiveIndex = (index: number, offset: number = 1): number => {
+ const len = flattenOptions.length;
+
+ for (let i = 0; i < len; i += 1) {
+ const current = (index + i * offset + len) % len;
+
+ const { group, data } = flattenOptions[current];
+ if (!group && !(data as OptionData).disabled) {
+ return current;
+ }
+ }
+
+ return -1;
+ };
+
+ const [activeIndex, setActiveIndex] = React.useState(() => getEnabledActiveIndex(0));
+ const setActive = (index: number) => {
+ setActiveIndex(index);
+
+ // Trigger active event
+ const flattenItem = flattenOptions[index];
+ if (!flattenItem) {
+ onActiveValue(null, -1);
+ return;
+ }
+
+ onActiveValue((flattenItem.data as OptionData).value, index);
+ };
+
+ // Auto active first item when list length or searchValue changed
+ React.useEffect(() => {
+ setActive(defaultActiveFirstOption !== false ? getEnabledActiveIndex(0) : -1);
+ }, [flattenOptions.length, searchValue]);
+
+ // Auto scroll to item position in single mode
+ React.useEffect(() => {
+ if (!multiple && open && values.size === 1) {
+ const value: RawValueType = Array.from(values)[0];
+ const index = flattenOptions.findIndex(({ data }) => (data as OptionData).value === value);
+ setActive(index);
+ scrollIntoView(index);
+ }
+ }, [open]);
+
+ // ========================== Values ==========================
+ const onSelectValue = (value: RawValueType) => {
+ if (value !== null) {
+ onSelect(value, { selected: !values.has(value) });
+ }
+
+ // Single mode should always close by select
+ if (!multiple) {
+ onToggleOpen(false);
+ }
+ };
+
+ // ========================= Keyboard =========================
+ React.useImperativeHandle(ref, () => ({
+ onKeyDown: ({ which }) => {
+ 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 nextActiveIndex = getEnabledActiveIndex(activeIndex + offset, offset);
+ scrollIntoView(nextActiveIndex);
+ setActive(nextActiveIndex);
+ }
+
+ break;
+ }
+
+ // >>> Select
+ case KeyCode.ENTER: {
+ // value
+ const item = flattenOptions[activeIndex];
+ if (item && !(item.data as OptionData).disabled) {
+ onSelectValue((item.data as OptionData).value);
+ } else {
+ onSelectValue(null);
+ }
+
+ break;
+ }
+
+ // >>> Close
+ case KeyCode.ESC: {
+ onToggleOpen(false);
+ }
+ }
+ },
+ onKeyUp: () => {},
+ }));
+
+ // ========================== Render ==========================
+ if (flattenOptions.length === 0) {
+ return (
+
+ {notFoundContent}
+
+ );
+ }
+
+ function renderItem(index: number) {
+ const item = flattenOptions[index];
+ const value = item && (item.data as OptionData).value;
+ return item ? (
+
+ {value}
+
+ ) : null;
+ }
+
+ return (
+ <>
+
+ {renderItem(activeIndex - 1)}
+ {renderItem(activeIndex)}
+ {renderItem(activeIndex + 1)}
+
+
+ itemKey="key"
+ ref={listRef}
+ data={flattenOptions}
+ height={height}
+ itemHeight={itemHeight}
+ onMouseDown={onListMouseDown}
+ onScroll={onScroll}
+ >
+ {({ group, groupOption, data }, itemIndex) => {
+ const { label, key } = data;
+
+ // Group
+ if (group) {
+ return (
+
+ {label !== undefined ? label : key}
+
+ );
+ }
+
+ const { disabled, value, children } = data as OptionData;
+
+ // Option
+ const selected = values.has(value);
+
+ const optionPrefixCls = `${itemPrefixCls}-option`;
+ const optionClassName = classNames(itemPrefixCls, optionPrefixCls, {
+ [`${optionPrefixCls}-grouped`]: groupOption,
+ [`${optionPrefixCls}-active`]: activeIndex === itemIndex && !disabled,
+ [`${optionPrefixCls}-disabled`]: disabled,
+ [`${optionPrefixCls}-selected`]: selected,
+ });
+
+ const mergedLabel = childrenAsData ? children : label;
+
+ const iconVisible =
+ !menuItemSelectedIcon || typeof menuItemSelectedIcon === 'function' || selected;
+
+ return (
+ {
+ if (activeIndex === itemIndex || disabled) {
+ return;
+ }
+
+ setActive(itemIndex);
+ }}
+ onClick={() => {
+ if (!disabled) {
+ onSelectValue(value);
+ }
+ }}
+ >
+
{mergedLabel || value}
+ {React.isValidElement(menuItemSelectedIcon) || selected}
+ {iconVisible && (
+
+ {selected ? '✓' : null}
+
+ )}
+
+ );
+ }}
+
+ >
+ );
+};
+
+const RefOptionList = React.forwardRef>(
+ OptionList,
+);
+RefOptionList.displayName = 'OptionList';
+
+export default RefOptionList;
diff --git a/src/PropTypes.ts b/src/PropTypes.ts
deleted file mode 100644
index 745d923f3..000000000
--- a/src/PropTypes.ts
+++ /dev/null
@@ -1,177 +0,0 @@
-import * as PropTypes from 'prop-types';
-import {
- CSSProperties,
- KeyboardEventHandler,
- MouseEventHandler,
- ReactNode,
- UIEventHandler,
-} from 'react';
-
-export type emptyFunction = (e?: any) => void;
-
-export interface IILableValueType {
- key?: string | number;
- label?: ReactNode | string;
-}
-export type valueType =
- | number
- | number[]
- | string
- | string[]
- | IILableValueType
- | IILableValueType[];
-
-type renderNode = () => ReactNode;
-export type filterOptionType = (inputValue: string, option?: any) => void;
-export type renderSelect = ReactNode | renderNode;
-
-export interface ISelectProps {
- id: string;
- defaultActiveFirstOption: boolean;
- multiple: boolean;
- combobox: boolean;
- autoClearSearchValue: boolean;
- filterOption: filterOptionType | boolean;
- children: JSX.Element[] | JSX.Element | any;
- showSearch: boolean;
- disabled: boolean;
- style: CSSProperties;
- allowClear: boolean;
- showArrow: boolean;
- tags: boolean;
- openClassName: string;
- autoFocus: boolean;
- prefixCls: string;
- className: string;
- transitionName: string;
- optionLabelProp: string;
- optionFilterProp: string;
- animation: string;
- choiceTransitionName: string;
- open: boolean;
- defaultOpen: boolean;
- inputValue: string;
- onChange: (value: valueType, option: JSX.Element | JSX.Element[]) => void;
- onBlur: emptyFunction;
- onFocus: emptyFunction;
- onSelect: (value: valueType, option: JSX.Element | JSX.Element[]) => void;
- onSearch: (value: string) => void;
- onDropdownVisibleChange: (open: boolean | undefined) => void;
- onPopupScroll: UIEventHandler;
- onMouseEnter: MouseEventHandler;
- onMouseLeave: MouseEventHandler;
- onInputKeyDown: KeyboardEventHandler;
- placeholder: string;
- onDeselect: (value: valueType, option: JSX.Element | JSX.Element[]) => void;
- labelInValue: boolean;
- loading: boolean;
- value: valueType;
- firstActiveValue: valueType;
- defaultValue: valueType;
- dropdownStyle: CSSProperties;
- maxTagTextLength: number;
- maxTagCount: number;
- maxTagPlaceholder: renderSelect;
- tokenSeparators: string[];
- getInputElement: () => JSX.Element;
- showAction: string[];
- clearIcon: ReactNode;
- inputIcon: ReactNode;
- removeIcon: ReactNode;
- menuItemSelectedIcon: renderSelect;
- getPopupContainer: renderSelect;
- dropdownRender: (menu: any) => JSX.Element;
- mode: 'multiple' | 'tags';
- backfill: boolean;
- dropdownAlign: any;
- dropdownClassName: string;
- dropdownMatchSelectWidth: boolean;
- dropdownMenuStyle: React.CSSProperties;
- notFoundContent: string | false;
- tabIndex: string | number;
-}
-
-function propsValueType(...args: [ISelectProps, string, string, any, any]) {
- const [props, propName, componentName, ...rest] = args;
- const basicType = PropTypes.oneOfType([PropTypes.string, PropTypes.number]);
-
- const labelInValueShape = PropTypes.shape({
- key: basicType.isRequired,
- label: PropTypes.node,
- });
-
- if (props.labelInValue) {
- const validate = PropTypes.oneOfType([PropTypes.arrayOf(labelInValueShape), labelInValueShape]);
- const error = validate(props, propName, componentName, ...rest);
- if (error) {
- return new Error(
- `Invalid prop \`${propName}\` supplied to \`${componentName}\`, ` +
- `when you set \`labelInValue\` to \`true\`, \`${propName}\` should in ` +
- `shape of \`{ key: string | number, label?: ReactNode }\`.`,
- );
- }
- } else if (
- (props.mode === 'multiple' || props.mode === 'tags' || props.multiple || props.tags) &&
- props[propName] === ''
- ) {
- return new Error(
- `Invalid prop \`${propName}\` of type \`string\` supplied to \`${componentName}\`, ` +
- `expected \`array\` when \`multiple\` or \`tags\` is \`true\`.`,
- );
- } else {
- const validate = PropTypes.oneOfType([PropTypes.arrayOf(basicType), basicType]);
- return validate(props, propName, componentName, ...rest);
- }
- return null;
-}
-
-const SelectPropTypes = {
- id: PropTypes.string,
- defaultActiveFirstOption: PropTypes.bool,
- multiple: PropTypes.bool,
- filterOption: PropTypes.any,
- children: PropTypes.any,
- showSearch: PropTypes.bool,
- disabled: PropTypes.bool,
- allowClear: PropTypes.bool,
- showArrow: PropTypes.bool,
- tags: PropTypes.bool,
- prefixCls: PropTypes.string,
- className: PropTypes.string,
- transitionName: PropTypes.string,
- optionLabelProp: PropTypes.string,
- optionFilterProp: PropTypes.string,
- animation: PropTypes.string,
- choiceTransitionName: PropTypes.string,
- open: PropTypes.bool,
- defaultOpen: PropTypes.bool,
- onChange: PropTypes.func,
- onBlur: PropTypes.func,
- onFocus: PropTypes.func,
- onSelect: PropTypes.func,
- onSearch: PropTypes.func,
- onPopupScroll: PropTypes.func,
- onMouseEnter: PropTypes.func,
- onMouseLeave: PropTypes.func,
- onInputKeyDown: PropTypes.func,
- placeholder: PropTypes.any,
- onDeselect: PropTypes.func,
- labelInValue: PropTypes.bool,
- loading: PropTypes.bool,
- value: propsValueType,
- defaultValue: propsValueType,
- dropdownStyle: PropTypes.object,
- maxTagTextLength: PropTypes.number,
- maxTagCount: PropTypes.number,
- maxTagPlaceholder: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
- tokenSeparators: PropTypes.arrayOf(PropTypes.string),
- getInputElement: PropTypes.func,
- showAction: PropTypes.arrayOf(PropTypes.string),
- clearIcon: PropTypes.node,
- inputIcon: PropTypes.node,
- removeIcon: PropTypes.node,
- menuItemSelectedIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.node]),
- dropdownRender: PropTypes.func,
-};
-
-export default SelectPropTypes;
diff --git a/src/Select.tsx b/src/Select.tsx
index 8dae1ca3d..129ad1a36 100644
--- a/src/Select.tsx
+++ b/src/Select.tsx
@@ -1,1633 +1,80 @@
-import classnames from 'classnames';
-import classes from 'component-classes';
-import Animate from 'rc-animate';
-import { Item as MenuItem, ItemGroup as MenuItemGroup } from 'rc-menu';
-import childrenToArray from 'rc-util/lib/Children/toArray';
-import KeyCode from 'rc-util/lib/KeyCode';
-import * as React from 'react';
-import * as ReactDOM from 'react-dom';
-import { polyfill } from 'react-lifecycles-compat';
-import warning from 'warning';
-import OptGroup from './OptGroup';
+/**
+ * To match accessibility requirement, we always provide an input in the component.
+ * Other element will not set `tabIndex` to avoid `onBlur` sequence problem.
+ * For focused select, we set `aria-live="polite"` to update the accessibility content.
+ *
+ * ref:
+ * - keyboard: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role#Keyboard_interactions
+ *
+ * New api:
+ * - listHeight
+ * - listItemHeight
+ * - component
+ *
+ * Remove deprecated api:
+ * - multiple
+ * - tags
+ * - combobox
+ * - firstActiveValue
+ * - dropdownMenuStyle
+ * - openClassName (Not list in api)
+ *
+ * Update:
+ * - `backfill` only support `combobox` mode
+ * - `combobox` mode not support `labelInValue` since it's meaningless
+ * - `getInputElement` only support `combobox` mode
+ * - `onChange` return OptionData instead of ReactNode
+ * - `filterOption` `onChange` `onSelect` accept OptionData instead of ReactNode
+ * - `combobox` mode trigger `onChange` will get `undefined` if no `value` match in Option
+ * - `combobox` mode not support `optionLabelProp`
+ */
+
+import { OptionsType as SelectOptionsType } from './interface';
+import SelectOptionList from './OptionList';
import Option from './Option';
-
-import SelectPropTypes, { IILableValueType, ISelectProps, valueType } from './PropTypes';
-import SelectTrigger from './SelectTrigger';
+import OptGroup from './OptGroup';
+import { convertChildrenToData as convertSelectChildrenToData } from './utils/legacyUtil';
import {
- defaultFilterFn,
- findFirstMenuItem,
- findIndexInValueBySingleValue,
- generateUUID,
- getLabelFromPropsValue,
- getMapKey,
- getPropValue,
- getValuePropValue,
- includesSeparators,
- isCombobox,
- isMultipleOrTags,
- isMultipleOrTagsOrCombobox,
- isSingleMode,
- preventDefaultEvent,
- saveRef,
- splitBySeparators,
- toArray,
- toTitle,
- UNSELECTABLE_ATTRIBUTE,
- UNSELECTABLE_STYLE,
- validateOptionValue,
-} from './util';
-
-const SELECT_EMPTY_VALUE_KEY = 'RC_SELECT_EMPTY_VALUE_KEY';
-
-const noop = () => null;
-
-function chaining(...fns: any[]) {
- return (...args: any[]) => {
- // tslint:disable-next-line:prefer-for-of
- for (let i = 0; i < fns.length; i++) {
- if (fns[i] && typeof fns[i] === 'function') {
- fns[i].apply(chaining, args);
- }
- }
- };
-}
-
-export interface ISelectState {
- open: boolean;
- value?: valueType;
- inputValue?: string;
- skipBuildOptionsInfo?: boolean;
- optionsInfo?: any;
- backfillValue?: string;
- ariaId?: string;
+ getLabeledValue as getSelectLabeledValue,
+ filterOptions as selectDefaultFilterOptions,
+ isValueDisabled as isSelectValueDisabled,
+ findValueOption as findSelectValueOption,
+ flattenOptions,
+ fillOptionsWithMissingValue,
+} from './utils/valueUtil';
+import generateSelector, { SelectProps } from './generate';
+import { DefaultValueType } from './interface/generator';
+import warningProps from './utils/warningPropsUtil';
+
+interface SelectStaticProps {
+ Option: typeof Option;
+ OptGroup: typeof OptGroup;
}
-class Select extends React.Component, ISelectState> {
- public static propTypes = SelectPropTypes;
- public static Option: typeof Option;
- public static OptGroup: typeof OptGroup;
- public static displayName: string;
- public static defaultProps = {
- prefixCls: 'rc-select',
- defaultOpen: false,
- labelInValue: false,
- defaultActiveFirstOption: true,
- showSearch: true,
- allowClear: false,
- placeholder: '',
- onChange: noop,
- onFocus: noop,
- onBlur: noop,
- onSelect: noop,
- onSearch: noop,
- onDeselect: noop,
- onInputKeyDown: noop,
- dropdownMatchSelectWidth: true,
- dropdownStyle: {},
- dropdownMenuStyle: {},
- optionFilterProp: 'value',
- optionLabelProp: 'value',
- notFoundContent: 'Not Found',
- backfill: false,
- showAction: ['click'],
- tokenSeparators: [],
- autoClearSearchValue: true,
- tabIndex: 0,
- dropdownRender: (menu: any) => menu,
- };
- public static getDerivedStateFromProps = (nextProps: ISelectProps, prevState: ISelectState) => {
- const optionsInfo = prevState.skipBuildOptionsInfo
- ? prevState.optionsInfo
- : Select.getOptionsInfoFromProps(nextProps, prevState);
-
- const newState: Partial = {
- optionsInfo,
- skipBuildOptionsInfo: false,
- };
-
- if ('open' in nextProps) {
- newState.open = nextProps.open;
- }
-
- if ('value' in nextProps) {
- const value = Select.getValueFromProps(nextProps);
- newState.value = value;
- if (nextProps.combobox) {
- newState.inputValue = Select.getInputValueForCombobox(nextProps, optionsInfo);
- }
- }
- return newState;
- };
-
- public static getOptionsFromChildren = (
- children: Array>,
- options: any[] = [],
- ) => {
- React.Children.forEach(children, child => {
- if (!child) {
- return;
- }
- const type = (child as React.ReactElement).type as any;
- if (type.isSelectOptGroup) {
- Select.getOptionsFromChildren((child as React.ReactElement).props.children, options);
- } else {
- options.push(child);
- }
- });
- return options;
- };
-
- public static getInputValueForCombobox = (
- props: Partial,
- optionsInfo: any,
- useDefaultValue?: boolean,
- ) => {
- let value: valueType | undefined = [];
- if ('value' in props && !useDefaultValue) {
- value = toArray(props.value);
- }
- if ('defaultValue' in props && useDefaultValue) {
- value = toArray(props.defaultValue);
- }
- if ((value as string[]).length) {
- value = (value as string[])[0];
- } else {
- return '';
- }
- let label = value;
- if (props.labelInValue) {
- label = (value as IILableValueType).label as string;
- } else if (optionsInfo[getMapKey(value)]) {
- label = optionsInfo[getMapKey(value)].label;
- }
- if (label === undefined) {
- label = '';
- }
- return label;
- };
-
- public static getLabelFromOption = (props: Partial, option: any) => {
- return getPropValue(option, props.optionLabelProp);
- };
-
- public static getOptionsInfoFromProps = (
- props: Partial,
- preState?: ISelectState,
- ) => {
- const options = Select.getOptionsFromChildren(props.children);
- const optionsInfo = {};
- options.forEach(option => {
- const singleValue = getValuePropValue(option);
- optionsInfo[getMapKey(singleValue)] = {
- option,
- value: singleValue,
- label: Select.getLabelFromOption(props, option),
- title: option.props.title,
- disabled: option.props.disabled,
- };
- });
- if (preState) {
- // keep option info in pre state value.
- const oldOptionsInfo = preState.optionsInfo;
- const value = preState.value;
- if (value) {
- (value as string[]).forEach(v => {
- const key = getMapKey(v);
- if (!optionsInfo[key] && oldOptionsInfo[key] !== undefined) {
- optionsInfo[key] = oldOptionsInfo[key];
- }
- });
- }
- }
- return optionsInfo;
- };
-
- public static getValueFromProps = (props: Partial, useDefaultValue?: boolean) => {
- let value: valueType | undefined = [];
- if ('value' in props && !useDefaultValue) {
- value = toArray(props.value);
- }
- if ('defaultValue' in props && useDefaultValue) {
- value = toArray(props.defaultValue);
- }
- if (props.labelInValue) {
- value = (value as IILableValueType[]).map(v => {
- return v.key as string;
- });
- }
- return value;
- };
-
- public saveInputRef: (ref: HTMLInputElement) => void;
- public saveInputMirrorRef: (ref: HTMLSpanElement) => void;
- public saveTopCtrlRef: (ref: HTMLDivElement) => void;
- public saveSelectTriggerRef: (ref: SelectTrigger) => void;
- public saveRootRef: (ref: HTMLDivElement) => void;
- public saveSelectionRef: (ref: HTMLDivElement) => void;
- public inputRef: HTMLInputElement | null = null;
- public inputMirrorRef: HTMLSpanElement | null = null;
- public topCtrlRef: HTMLDivElement | null = null;
- public selectTriggerRef: SelectTrigger | null = null;
- public rootRef: HTMLDivElement | null = null;
- public selectionRef: HTMLDivElement | null = null;
- public dropdownContainer: Element | null = null;
- public blurTimer: number | null = null;
- public focusTimer: number | null = null;
- public comboboxTimer: number | null = null;
-
- // tslint:disable-next-line:variable-name
- private _focused: boolean = false;
- // tslint:disable-next-line:variable-name
- private _mouseDown: boolean = false;
- // tslint:disable-next-line:variable-name
- private _options: JSX.Element[] = [];
- // tslint:disable-next-line:variable-name
- private _empty: boolean = false;
- constructor(props: Partial) {
- super(props);
- const optionsInfo = Select.getOptionsInfoFromProps(props);
- if (props.tags && typeof props.filterOption !== 'function') {
- const isDisabledExist = Object.keys(optionsInfo).some(key => optionsInfo[key].disabled);
- warning(
- !isDisabledExist,
- 'Please avoid setting option to disabled in tags mode since user can always type text as tag.',
- );
- }
- this.state = {
- value: Select.getValueFromProps(props, true), // true: use default value
- inputValue: props.combobox
- ? Select.getInputValueForCombobox(
- props,
- optionsInfo,
- true, // use default value
- )
- : '',
- open: props.defaultOpen as boolean,
- optionsInfo,
- backfillValue: '',
- // a flag for aviod redundant getOptionsInfoFromProps call
- skipBuildOptionsInfo: true,
- ariaId: '',
- };
-
- this.saveInputRef = saveRef(this, 'inputRef');
- this.saveInputMirrorRef = saveRef(this, 'inputMirrorRef');
- this.saveTopCtrlRef = saveRef(this, 'topCtrlRef');
- this.saveSelectTriggerRef = saveRef(this, 'selectTriggerRef');
- this.saveRootRef = saveRef(this, 'rootRef');
- this.saveSelectionRef = saveRef(this, 'selectionRef');
- }
-
- public componentDidMount() {
- // when defaultOpen is true, we should auto focus search input
- // https://github.com/ant-design/ant-design/issues/14254
- if (this.props.autoFocus || this.state.open) {
- this.focus();
- }
- this.setState({
- ariaId: generateUUID(),
- });
- }
-
- public componentDidUpdate() {
- if (isMultipleOrTags(this.props)) {
- const inputNode = this.getInputDOMNode();
- const mirrorNode = this.getInputMirrorDOMNode();
- if (inputNode && inputNode.value && mirrorNode) {
- inputNode.style.width = '';
- inputNode.style.width = `${mirrorNode.clientWidth}px`;
- } else if (inputNode) {
- inputNode.style.width = '';
- }
- }
- this.forcePopupAlign();
- }
- public componentWillUnmount() {
- this.clearFocusTime();
- this.clearBlurTime();
- this.clearComboboxTime();
- if (this.dropdownContainer) {
- ReactDOM.unmountComponentAtNode(this.dropdownContainer);
- document.body.removeChild(this.dropdownContainer);
- this.dropdownContainer = null;
- }
- }
-
- public onInputChange = (event: React.ChangeEvent) => {
- const tokenSeparators = this.props.tokenSeparators as string[];
- const val = event.target.value;
- if (
- isMultipleOrTags(this.props) &&
- tokenSeparators.length &&
- includesSeparators(val, tokenSeparators)
- ) {
- const nextValue = this.getValueByInput(val);
- if (nextValue !== undefined) {
- this.fireChange(nextValue);
- }
- this.setOpenState(false, { needFocus: true });
- this.setInputValue('', false);
- return;
- }
- this.setInputValue(val);
- this.setState({
- open: true,
- });
- if (isCombobox(this.props)) {
- this.fireChange([val]);
- }
- };
-
- public onDropdownVisibleChange = (open: boolean) => {
- if (open && !this._focused) {
- this.clearBlurTime();
- this.timeoutFocus();
- this._focused = true;
- this.updateFocusClassName();
- }
- this.setOpenState(open);
- };
-
- // combobox ignore
- public onKeyDown = (event: KeyboardEvent) => {
- const { open } = this.state;
- const { disabled } = this.props;
- if (disabled) {
- return;
- }
- const keyCode = event.keyCode;
- if (open && !this.getInputDOMNode()) {
- this.onInputKeyDown(event);
- } else if (keyCode === KeyCode.ENTER || keyCode === KeyCode.DOWN) {
- if (!open) {
- this.setOpenState(true);
- }
- event.preventDefault();
- } else if (keyCode === KeyCode.SPACE) {
- // Not block space if popup is shown
- if (!open) {
- this.setOpenState(true);
- event.preventDefault();
- }
- }
- };
-
- public onInputKeyDown = (event: React.ChangeEvent | KeyboardEvent) => {
- const { disabled, combobox, defaultActiveFirstOption } = this.props;
- if (disabled) {
- return;
- }
- const state = this.state;
- const isRealOpen = this.getRealOpenState(state);
-
- // magic code
- const keyCode = (event as KeyboardEvent).keyCode;
- if (
- isMultipleOrTags(this.props) &&
- !(event as React.ChangeEvent).target.value &&
- keyCode === KeyCode.BACKSPACE
- ) {
- event.preventDefault();
- const value = state.value as string[];
- if (value.length) {
- this.removeSelected(value[value.length - 1]);
- }
- return;
- }
- if (keyCode === KeyCode.DOWN) {
- if (!state.open) {
- this.openIfHasChildren();
- event.preventDefault();
- event.stopPropagation();
- return;
- }
- } else if (keyCode === KeyCode.ENTER && state.open) {
- // Aviod trigger form submit when select item
- // https://github.com/ant-design/ant-design/issues/10861
- // https://github.com/ant-design/ant-design/issues/14544
- if (isRealOpen || !combobox) {
- event.preventDefault();
- }
-
- // Hard close popup to avoid lock of non option in combobox mode
- if (isRealOpen && combobox && defaultActiveFirstOption === false) {
- this.comboboxTimer = setTimeout(() => {
- this.setOpenState(false);
- });
- }
- } else if (keyCode === KeyCode.ESC) {
- if (state.open) {
- this.setOpenState(false);
- event.preventDefault();
- event.stopPropagation();
- }
- return;
- }
-
- if (isRealOpen && this.selectTriggerRef) {
- const menu = this.selectTriggerRef.getInnerMenu();
- if (menu && menu.onKeyDown(event, this.handleBackfill)) {
- event.preventDefault();
- event.stopPropagation();
- }
- }
- };
-
- public onMenuSelect = ({ item }: { item: Option }) => {
- if (!item) {
- return;
- }
-
- let value = this.state.value as string[];
- const props = this.props;
- const selectedValue = getValuePropValue(item);
- const lastValue = value[value.length - 1];
-
- let skipTrigger = false;
-
- if (isMultipleOrTags(props)) {
- if (findIndexInValueBySingleValue(value, selectedValue) !== -1) {
- skipTrigger = true;
- } else {
- value = value.concat([selectedValue]);
- }
- } else {
- if (
- !isCombobox(props) &&
- lastValue !== undefined &&
- lastValue === selectedValue &&
- selectedValue !== this.state.backfillValue
- ) {
- this.setOpenState(false, { needFocus: true, fireSearch: false });
- skipTrigger = true;
- } else {
- value = [selectedValue];
- this.setOpenState(false, { needFocus: true, fireSearch: false });
- }
- }
-
- if (!skipTrigger) {
- this.fireChange(value);
- }
- this.fireSelect(selectedValue);
-
- if (!skipTrigger) {
- const inputValue = isCombobox(props) ? getPropValue(item, props.optionLabelProp) : '';
-
- if (props.autoClearSearchValue) {
- this.setInputValue(inputValue, false);
- }
- }
- };
-
- public onMenuDeselect = ({ item, domEvent }: { item: any; domEvent: KeyboardEvent }) => {
- if (domEvent.type === 'keydown' && domEvent.keyCode === KeyCode.ENTER) {
- this.removeSelected(getValuePropValue(item));
- return;
- }
- if (domEvent.type === 'click') {
- this.removeSelected(getValuePropValue(item));
- }
- const props = this.props;
- if (props.autoClearSearchValue) {
- this.setInputValue('');
- }
- };
-
- public onArrowClick = (e: React.MouseEvent) => {
- e.stopPropagation();
- e.preventDefault();
- if (!this.props.disabled) {
- this.setOpenState(!this.state.open, { needFocus: !this.state.open });
- }
- };
-
- public onPlaceholderClick = () => {
- if (this.getInputDOMNode && this.getInputDOMNode()) {
- (this.getInputDOMNode() as HTMLInputElement).focus();
- }
- };
-
- public onOuterFocus = (
- e: React.FocusEvent | React.FocusEvent,
- ) => {
- if (this.props.disabled) {
- e.preventDefault();
- return;
- }
- this.clearBlurTime();
-
- // In IE11, onOuterFocus will be trigger twice when focus input
- // First one: e.target is div
- // Second one: e.target is input
- // other browser only trigger second one
- // https://github.com/ant-design/ant-design/issues/15942
- // Here we ignore the first one when e.target is div
- const inputNode = this.getInputDOMNode();
- if (inputNode && e.target === this.rootRef) {
- return;
- }
-
- if (!isMultipleOrTagsOrCombobox(this.props) && e.target === inputNode) {
- return;
- }
- if (this._focused) {
- return;
- }
- this._focused = true;
- this.updateFocusClassName();
- // only effect multiple or tag mode
- if (!isMultipleOrTags(this.props) || !this._mouseDown) {
- this.timeoutFocus();
- }
- };
-
- public onPopupFocus = () => {
- // fix ie scrollbar, focus element again
- this.maybeFocus(true, true);
- };
-
- public onOuterBlur = (e: React.FocusEvent) => {
- if (this.props.disabled) {
- e.preventDefault();
- return;
- }
- this.blurTimer = window.setTimeout(() => {
- this._focused = false;
- this.updateFocusClassName();
- const props = this.props;
- let { value } = this.state;
- const { inputValue } = this.state;
- if (isSingleMode(props) && props.showSearch && inputValue && props.defaultActiveFirstOption) {
- const options = this._options || [];
- if (options.length) {
- const firstOption = findFirstMenuItem(options);
- if (firstOption) {
- value = [getValuePropValue(firstOption)];
- this.fireChange(value);
- }
- }
- } else if (isMultipleOrTags(props) && inputValue) {
- if (this._mouseDown) {
- // need update dropmenu when not blur
- this.setInputValue('');
- } else {
- // why not use setState?
- // https://github.com/ant-design/ant-design/issues/14262
- (this.state as any).inputValue = '';
- if (this.getInputDOMNode && this.getInputDOMNode()) {
- (this.getInputDOMNode() as HTMLInputElement).value = '';
- }
- }
- const tmpValue = this.getValueByInput(inputValue);
- if (tmpValue !== undefined) {
- value = tmpValue;
- this.fireChange(value);
- }
- }
-
- // if click the rest space of Select in multiple mode
- if (isMultipleOrTags(props) && this._mouseDown) {
- this.maybeFocus(true, true);
- this._mouseDown = false;
- return;
- }
- this.setOpenState(false);
- if (props.onBlur) {
- props.onBlur(this.getVLForOnChange(value as valueType));
- }
- }, 10);
- };
-
- public onClearSelection = (event: Event) => {
- const props = this.props;
- const state = this.state;
- if (props.disabled) {
- return;
- }
- const { inputValue } = state;
- const value = state.value as string[];
- event.stopPropagation();
- if (inputValue || value.length) {
- if (value.length) {
- this.fireChange([]);
- }
- this.setOpenState(false, { needFocus: true });
- if (inputValue) {
- this.setInputValue('');
- }
- }
- };
-
- public onChoiceAnimationLeave = () => {
- this.forcePopupAlign();
- };
-
- public getOptionInfoBySingleValue = (value: valueType, optionsInfo?: any) => {
- let info: any;
- optionsInfo = optionsInfo || this.state.optionsInfo;
- if (optionsInfo[getMapKey(value)]) {
- info = optionsInfo[getMapKey(value)];
- }
- if (info) {
- return info;
- }
- let defaultLabel = value;
- if (this.props.labelInValue) {
- const valueLabel = getLabelFromPropsValue(this.props.value, value);
- const defaultValueLabel = getLabelFromPropsValue(this.props.defaultValue, value);
- if (valueLabel !== undefined) {
- defaultLabel = valueLabel;
- } else if (defaultValueLabel !== undefined) {
- defaultLabel = defaultValueLabel;
- }
- }
- const defaultInfo = {
- option: (
-
- ),
- value,
- label: defaultLabel,
- };
- return defaultInfo;
- };
-
- public getOptionBySingleValue = (value: valueType) => {
- const { option } = this.getOptionInfoBySingleValue(value);
- return option;
- };
-
- public getOptionsBySingleValue = (values: valueType) => {
- return (values as string[]).map(value => {
- return this.getOptionBySingleValue(value);
- });
- };
-
- public getValueByLabel = (label?: string) => {
- if (label === undefined) {
- return null;
- }
- let value = null;
- Object.keys(this.state.optionsInfo).forEach(key => {
- const info = this.state.optionsInfo[key];
- const { disabled } = info;
- if (disabled) {
- return;
- }
- const oldLable = toArray(info.label) as string[];
- if (oldLable && oldLable.join('') === label) {
- value = info.value;
- }
- });
- return value;
- };
-
- public getVLBySingleValue = (value: valueType) => {
- if (this.props.labelInValue) {
- return {
- key: value,
- label: this.getLabelBySingleValue(value),
- };
- }
- return value;
- };
-
- public getVLForOnChange = (vlsS: valueType) => {
- let vls = vlsS;
- if (vls !== undefined) {
- if (!this.props.labelInValue) {
- vls = (vls as Array<{ key: string | number; label?: React.ReactNode }>).map((v: any) => v);
- } else {
- vls = (vls as string[]).map((vl: string) => ({
- key: vl,
- label: this.getLabelBySingleValue(vl),
- }));
- }
- return isMultipleOrTags(this.props) ? vls : vls[0];
- }
- return vls;
- };
-
- public getLabelBySingleValue = (value: valueType, optionsInfo?: any) => {
- const { label } = this.getOptionInfoBySingleValue(value, optionsInfo);
- return label;
- };
-
- public getDropdownContainer = () => {
- if (!this.dropdownContainer) {
- this.dropdownContainer = document.createElement('div');
- document.body.appendChild(this.dropdownContainer);
- }
- return this.dropdownContainer;
- };
-
- public getPlaceholderElement = () => {
- const props = this.props;
- const state = this.state;
- let hidden = false;
- if (state.inputValue) {
- hidden = true;
- }
- const value = state.value as string[];
- if (value.length) {
- hidden = true;
- }
- if (isCombobox(props) && value.length === 1 && (state.value && !state.value[0])) {
- hidden = false;
- }
- const placeholder = props.placeholder;
- if (placeholder) {
- return (
-
- {placeholder}
-
- );
- }
- return null;
- };
-
- public getInputElement = () => {
- const props = this.props;
- const defaultInput = ;
- // tslint:disable-next-line:typedef-whitespace
- const inputElement: JSX.Element = props.getInputElement
- ? props.getInputElement()
- : defaultInput;
- const inputCls = classnames(inputElement.props.className, {
- [`${props.prefixCls}-search__field`]: true,
- });
- // https://github.com/ant-design/ant-design/issues/4992#issuecomment-281542159
- // Add space to the end of the inputValue as the width measurement tolerance
- return (
-
- {React.cloneElement(inputElement, {
- ref: this.saveInputRef,
- onChange: this.onInputChange,
- onKeyDown: chaining(
- this.onInputKeyDown,
- inputElement.props.onKeyDown,
- this.props.onInputKeyDown,
- ),
- value: this.state.inputValue,
- disabled: props.disabled,
- className: inputCls,
- })}
-
- {this.state.inputValue}
-
-
- );
- };
-
- public getInputDOMNode = (): HTMLInputElement | null => {
- return this.topCtrlRef
- ? this.topCtrlRef.querySelector('input,textarea,div[contentEditable]')
- : this.inputRef;
- };
-
- public getInputMirrorDOMNode = () => {
- return this.inputMirrorRef;
- };
-
- public getPopupDOMNode = () => {
- if (this.selectTriggerRef) {
- return this.selectTriggerRef.getPopupDOMNode();
- }
- };
-
- public getPopupMenuComponent = () => {
- if (this.selectTriggerRef) {
- return this.selectTriggerRef.getInnerMenu();
- }
- };
-
- public setOpenState = (
- open: boolean,
- config: { needFocus?: boolean; fireSearch?: boolean } = {},
- ) => {
- const { needFocus, fireSearch } = config;
- const props = this.props;
- const state = this.state;
-
- if (state.open === open) {
- this.maybeFocus(open, !!needFocus);
- return;
- }
-
- if (this.props.onDropdownVisibleChange) {
- this.props.onDropdownVisibleChange(open as boolean);
- }
-
- const nextState: ISelectState = {
- open,
- backfillValue: '',
- };
- // clear search input value when open is false in singleMode.
- // https://github.com/ant-design/ant-design/issues/16572
- if (!open && isSingleMode(props) && props.showSearch) {
- this.setInputValue('', fireSearch);
- }
- if (!open) {
- this.maybeFocus(open, !!needFocus);
- }
- this.setState(
- {
- open,
- ...nextState,
- },
- () => {
- if (open) {
- this.maybeFocus(open, !!needFocus);
- }
- },
- );
- };
-
- public setInputValue = (inputValue: string, fireSearch = true) => {
- const { onSearch } = this.props;
- if (inputValue !== this.state.inputValue) {
- this.setState(prevState => {
- // Additional check if `inputValue` changed in latest state.
- if (fireSearch && inputValue !== prevState.inputValue && onSearch) {
- onSearch(inputValue);
- }
- return { inputValue };
- }, this.forcePopupAlign);
- }
- };
-
- public getValueByInput = (str: string | string[]) => {
- const { multiple, tokenSeparators } = this.props;
- let nextValue = this.state.value;
- let hasNewValue = false;
- splitBySeparators(str, tokenSeparators as string[]).forEach(label => {
- const selectedValue = [label];
- if (multiple) {
- const value = this.getValueByLabel(label);
- if (value && findIndexInValueBySingleValue(nextValue, value) === -1) {
- nextValue = (nextValue as string).concat(value);
- hasNewValue = true;
- this.fireSelect(value);
- }
- } else if (findIndexInValueBySingleValue(nextValue, label) === -1) {
- nextValue = (nextValue as string[]).concat(selectedValue);
- hasNewValue = true;
- this.fireSelect(label);
- }
- });
- return hasNewValue ? nextValue : undefined;
- };
-
- public getRealOpenState = (state?: ISelectState) => {
- // tslint:disable-next-line:variable-name
- const { open: _open } = this.props;
- if (typeof _open === 'boolean') {
- return _open;
- }
- let open = (state || this.state).open;
- const options = this._options || [];
- if (isMultipleOrTagsOrCombobox(this.props) || !this.props.showSearch) {
- if (open && !options.length) {
- open = false;
- }
- }
- return open;
- };
-
- public focus() {
- if (isSingleMode(this.props) && this.selectionRef) {
- this.selectionRef.focus();
- } else if (this.getInputDOMNode()) {
- (this.getInputDOMNode() as HTMLInputElement).focus();
- }
- }
-
- public blur() {
- if (isSingleMode(this.props) && this.selectionRef) {
- this.selectionRef.blur();
- } else if (this.getInputDOMNode()) {
- (this.getInputDOMNode() as HTMLInputElement).blur();
- }
- }
-
- public markMouseDown = () => {
- this._mouseDown = true;
- };
-
- public markMouseLeave = () => {
- this._mouseDown = false;
- };
-
- public handleBackfill = (item: JSX.Element) => {
- if (!this.props.backfill || !(isSingleMode(this.props) || isCombobox(this.props))) {
- return;
- }
-
- const key = getValuePropValue(item);
-
- if (isCombobox(this.props)) {
- this.setInputValue(key, false);
- }
-
- this.setState({
- value: [key],
- backfillValue: key,
- });
- };
-
- public filterOption = (input: string, child: JSX.Element, defaultFilter = defaultFilterFn) => {
- const value = this.state.value as string[];
- const lastValue = value[value.length - 1];
- if (!input || (lastValue && lastValue === this.state.backfillValue)) {
- return true;
- }
- let filterFn = this.props.filterOption;
- if ('filterOption' in this.props) {
- if (filterFn === true) {
- filterFn = defaultFilter.bind(this);
- }
- } else {
- filterFn = defaultFilter.bind(this);
- }
-
- if (!filterFn) {
- return true;
- } else if (typeof filterFn === 'function') {
- return filterFn.call(this, input, child);
- } else if (child.props.disabled) {
- return false;
- }
- return true;
- };
-
- public timeoutFocus = () => {
- const { onFocus } = this.props;
- if (this.focusTimer) {
- this.clearFocusTime();
- }
- this.focusTimer = window.setTimeout(() => {
- if (onFocus) {
- onFocus();
- }
- }, 10);
- };
-
- public clearFocusTime = () => {
- if (this.focusTimer) {
- clearTimeout(this.focusTimer);
- this.focusTimer = null;
- }
- };
-
- public clearBlurTime = () => {
- if (this.blurTimer) {
- clearTimeout(this.blurTimer);
- this.blurTimer = null;
- }
- };
-
- public clearComboboxTime = () => {
- if (this.comboboxTimer) {
- clearTimeout(this.comboboxTimer);
- this.comboboxTimer = null;
- }
- };
-
- public updateFocusClassName = () => {
- const rootRef = this.rootRef;
- const props = this.props;
- // avoid setState and its side effect
- if (this._focused) {
- classes(rootRef).add(`${props.prefixCls}-focused`);
- } else {
- classes(rootRef).remove(`${props.prefixCls}-focused`);
- }
- };
-
- public maybeFocus = (open: boolean, needFocus: boolean) => {
- if (needFocus || open) {
- const input = this.getInputDOMNode();
- const { activeElement } = document;
- if (input && (open || isMultipleOrTagsOrCombobox(this.props))) {
- if (activeElement !== input) {
- input.focus();
- this._focused = true;
- }
- } else if (activeElement !== this.selectionRef && this.selectionRef) {
- this.selectionRef.focus();
- this._focused = true;
- }
- }
- };
-
- public removeSelected = (
- selectedKey: string[] | string,
- e?: React.MouseEvent,
- ) => {
- const props = this.props;
- if (props.disabled || this.isChildDisabled(selectedKey)) {
- return;
- }
-
- // Do not trigger Trigger popup
- if (e && e.stopPropagation) {
- e.stopPropagation();
- }
- const oldValue = this.state.value as string[];
-
- const value = oldValue.filter(singleValue => {
- return singleValue !== selectedKey;
- });
- const canMultiple = isMultipleOrTags(props);
-
- if (canMultiple) {
- let event: valueType = selectedKey;
- if (props.labelInValue) {
- event = {
- key: selectedKey as string,
- label: this.getLabelBySingleValue(selectedKey),
- };
- }
- if (props.onDeselect) {
- props.onDeselect(event, this.getOptionBySingleValue(selectedKey));
- }
- }
- this.fireChange(value);
- };
-
- public openIfHasChildren = () => {
- const props = this.props;
- if (React.Children.count(props.children) || isSingleMode(props)) {
- this.setOpenState(true);
- }
- };
-
- public fireSelect = (value: valueType) => {
- if (this.props.onSelect) {
- this.props.onSelect(
- this.getVLBySingleValue(value) as valueType,
- this.getOptionBySingleValue(value),
- );
- }
- };
-
- public fireChange = (value: valueType) => {
- const props = this.props;
- if (!('value' in props)) {
- this.setState(
- {
- value,
- },
- this.forcePopupAlign,
- );
- }
- const vls = this.getVLForOnChange(value);
- const options = this.getOptionsBySingleValue(value);
- if (props.onChange) {
- props.onChange(vls, isMultipleOrTags(this.props) ? options : options[0]);
- }
- };
-
- public isChildDisabled = (key: string | string[]) => {
- return childrenToArray(this.props.children).some((child: React.ReactElement) => {
- const childValue = getValuePropValue(child);
- return childValue === key && child.props && child.props.disabled;
- });
- };
-
- public forcePopupAlign = () => {
- if (!this.state.open) {
- return;
- }
- if (this.selectTriggerRef && this.selectTriggerRef.triggerRef) {
- this.selectTriggerRef.triggerRef.forcePopupAlign();
- }
- };
-
- public renderFilterOptions = (): { empty: boolean; options: JSX.Element[] } => {
- const { inputValue } = this.state;
- const { children, tags, notFoundContent } = this.props;
- const menuItems: JSX.Element[] = [];
- const childrenKeys: string[] = [];
- let empty = false;
- let options = this.renderFilterOptionsFromChildren(children, childrenKeys, menuItems);
- if (tags) {
- // tags value must be string
- let value = this.state.value as string[];
- value = value.filter(singleValue => {
- return (
- childrenKeys.indexOf(singleValue) === -1 &&
- (!inputValue || String(singleValue).indexOf(String(inputValue)) > -1)
- );
- });
-
- // sort by length
- value.sort((val1, val2) => {
- return val1.length - val2.length;
- });
-
- value.forEach(singleValue => {
- const key = singleValue;
- const menuItem = (
-
- );
- options.push(menuItem);
- menuItems.push(menuItem);
- });
- // ref: https://github.com/ant-design/ant-design/issues/14090
- if (inputValue && menuItems.every(option => getValuePropValue(option) !== inputValue)) {
- options.unshift(
- ,
- );
- }
- }
-
- if (!options.length && notFoundContent) {
- empty = true;
- options = [
- ,
- ];
- }
- return { empty, options };
- };
-
- public renderFilterOptionsFromChildren = (
- children: Array>,
- childrenKeys: string[],
- menuItems: JSX.Element[],
- ): JSX.Element[] => {
- const sel: JSX.Element[] = [];
- const props = this.props;
- const { inputValue } = this.state;
- const tags = props.tags;
- React.Children.forEach(children, child => {
- if (!child) {
- return;
- }
- const type = (child as React.ReactElement).type as any;
- if (type.isSelectOptGroup) {
- let label = (child as React.ReactElement).props.label;
- let key = (child as React.ReactElement).key;
- if (!key && typeof label === 'string') {
- key = label;
- } else if (!label && key) {
- label = key;
- }
-
- // Match option group label
- if (
- inputValue &&
- this.filterOption(inputValue as string, child as React.ReactElement)
- ) {
- const innerItems = childrenToArray((child as React.ReactElement).props.children).map(
- (subChild: JSX.Element) => {
- const childValueSub = getValuePropValue(subChild) || subChild.key;
- return ;
- },
- );
-
- sel.push(
-
- {innerItems}
- ,
- );
-
- // Not match
- } else {
- const innerItems = this.renderFilterOptionsFromChildren(
- (child as React.ReactElement).props.children,
- childrenKeys,
- menuItems,
- );
- if (innerItems.length) {
- sel.push(
-
- {innerItems}
- ,
- );
- }
- }
-
- return;
- }
-
- warning(
- type.isSelectOption,
- 'the children of `Select` should be `Select.Option` or `Select.OptGroup`, ' +
- `instead of \`${type.name ||
- type.displayName ||
- (child as React.ReactElement).type}\`.`,
- );
-
- const childValue = getValuePropValue(child);
-
- validateOptionValue(childValue, this.props);
-
- if (this.filterOption(inputValue as string, child as React.ReactElement)) {
- const menuItem = (
-
}
+ popupAlign={dropdownAlign}
+ popupVisible={visible}
+ getPopupContainer={getPopupContainer}
+ popupClassName={classNames(dropdownClassName, {
+ [`${dropdownPrefixCls}-empty`]: empty,
+ })}
+ popupStyle={{
+ ...dropdownStyle,
+ width:
+ typeof dropdownMatchSelectWidth === 'number' ? dropdownMatchSelectWidth : containerWidth,
+ }}
+ >
+ {children}
+
+ );
+};
- public render() {
- const { onPopupFocus, empty, ...props } = this.props;
- const {
- multiple,
- visible,
- inputValue,
- dropdownAlign,
- disabled,
- showSearch,
- dropdownClassName,
- dropdownStyle,
- dropdownMatchSelectWidth,
- } = props;
- const dropdownPrefixCls = this.getDropdownPrefixCls();
- const popupClassName = {
- [dropdownClassName as string]: !!dropdownClassName,
- [`${dropdownPrefixCls}--${multiple ? 'multiple' : 'single'}`]: 1,
- [`${dropdownPrefixCls}--empty`]: empty,
- };
- const popupElement = this.getDropdownElement({
- menuItems: props.options,
- onPopupFocus,
- multiple,
- inputValue,
- visible,
- });
- let hideAction: string[];
- if (disabled) {
- hideAction = [];
- } else if (isSingleMode(props) && !showSearch) {
- hideAction = ['click'];
- } else {
- hideAction = ['blur'];
- }
- const popupStyle = { ...dropdownStyle };
- const widthProp = dropdownMatchSelectWidth ? 'width' : 'minWidth';
- if (this.state.dropdownWidth) {
- popupStyle[widthProp] = `${this.state.dropdownWidth}px`;
- }
- return (
-
- {props.children}
-
- );
- }
-}
+const RefSelectTrigger = React.forwardRef(SelectTrigger);
+RefSelectTrigger.displayName = 'SelectTrigger';
-SelectTrigger.displayName = 'SelectTrigger';
+export default RefSelectTrigger;
diff --git a/src/Selector/Input.tsx b/src/Selector/Input.tsx
new file mode 100644
index 000000000..3a242e9f4
--- /dev/null
+++ b/src/Selector/Input.tsx
@@ -0,0 +1,79 @@
+import React from 'react';
+
+type InputRef = HTMLInputElement | HTMLTextAreaElement;
+
+interface InputProps {
+ prefixCls: string;
+ id: string;
+ inputElement: React.ReactElement;
+ disabled: boolean;
+ autoFocus: boolean;
+ editable: boolean;
+ accessibilityIndex: number;
+ value: string;
+ open: boolean;
+ tabIndex: number;
+
+ onKeyDown: React.KeyboardEventHandler;
+ onChange: React.ChangeEventHandler;
+}
+
+const Input: React.RefForwardingComponent = (
+ {
+ prefixCls,
+ id,
+ inputElement,
+ disabled,
+ tabIndex,
+ autoFocus,
+ editable,
+ accessibilityIndex,
+ value,
+ onKeyDown,
+ onChange,
+ open,
+ },
+ ref,
+) => {
+ let inputNode: React.ReactElement = inputElement || ;
+
+ const { onKeyDown: onOriginKeyDown, onChange: onOriginChange } = inputNode.props;
+
+ inputNode = React.cloneElement(inputNode, {
+ id,
+ ref,
+ disabled,
+ tabIndex,
+ autoComplete: 'off',
+ autoFocus,
+ className: `${prefixCls}-selection-search-input`,
+ style: { opacity: editable ? null : 0 },
+ role: 'combobox',
+ 'aria-expanded': open,
+ 'aria-haspopup': 'listbox',
+ 'aria-owns': `${id}_list`,
+ 'aria-autocomplete': 'list',
+ 'aria-controls': `${id}_list`,
+ 'aria-activedescendant': `${id}_list_${accessibilityIndex}`,
+ value: editable ? value : '',
+ onKeyDown: (event: React.KeyboardEvent) => {
+ onKeyDown(event);
+ if (onOriginKeyDown) {
+ onOriginKeyDown(event);
+ }
+ },
+ onChange: (event: React.ChangeEvent) => {
+ onChange(event);
+ if (onOriginChange) {
+ onOriginChange(event);
+ }
+ },
+ });
+
+ return inputNode;
+};
+
+const RefInput = React.forwardRef(Input);
+RefInput.displayName = 'Input';
+
+export default RefInput;
diff --git a/src/Selector/MultipleSelector.tsx b/src/Selector/MultipleSelector.tsx
new file mode 100644
index 000000000..ed9748520
--- /dev/null
+++ b/src/Selector/MultipleSelector.tsx
@@ -0,0 +1,188 @@
+import React from 'react';
+import classNames from 'classnames';
+import CSSMotionList from 'rc-animate/lib/CSSMotionList';
+import TransBtn from '../TransBtn';
+import { LabelValueType, RawValueType } from '../interface/generator';
+import { RenderNode } from '../interface';
+import { InnerSelectorProps } from '.';
+import Input from './Input';
+import useLayoutEffect from '../hooks/useLayoutEffect';
+
+const REST_TAG_KEY = '__RC_SELECT_MAX_REST_COUNT__';
+
+interface SelectorProps extends InnerSelectorProps {
+ // Icon
+ removeIcon?: RenderNode;
+
+ // Tags
+ maxTagCount?: number;
+ maxTagTextLength?: number;
+ maxTagPlaceholder?: (omittedValues: LabelValueType[]) => React.ReactNode;
+ tokenSeparators?: string[];
+
+ // Motion
+ choiceTransitionName?: string;
+
+ // Event
+ onSelect: (value: RawValueType, option: { selected: boolean }) => void;
+}
+
+const SelectSelector: React.FC = ({
+ id,
+ prefixCls,
+
+ values,
+ open,
+ searchValue,
+ inputRef,
+ placeholder,
+ disabled,
+ mode,
+ showSearch,
+ autoFocus,
+ accessibilityIndex,
+
+ removeIcon,
+ choiceTransitionName,
+
+ maxTagCount,
+ maxTagTextLength,
+ maxTagPlaceholder = (omittedValues: LabelValueType[]) => `+ ${omittedValues.length} ...`,
+
+ onSelect,
+ onInputChange,
+ onInputKeyDown,
+}) => {
+ const [motionAppear, setMotionAppear] = React.useState(false);
+ const measureRef = React.useRef(null);
+ const [inputWidth, setInputWidth] = React.useState(0);
+
+ // ===================== Motion ======================
+ React.useEffect(() => {
+ setMotionAppear(true);
+ }, []);
+
+ // ===================== Search ======================
+ const inputEditable: boolean = mode === 'tags' || (open && showSearch);
+
+ // We measure width and set to the input immediately
+ useLayoutEffect(() => {
+ setInputWidth(measureRef.current.scrollWidth);
+ }, [searchValue]);
+
+ // ==================== Selection ====================
+ let displayValues: LabelValueType[] = values;
+
+ // Cut by `maxTagCount`
+ let restCount: number;
+ if (typeof maxTagCount === 'number') {
+ restCount = values.length - maxTagCount;
+ displayValues = values.slice(0, maxTagCount);
+ }
+
+ // Update by `maxTagTextLength`
+ if (typeof maxTagTextLength === 'number') {
+ displayValues = displayValues.map(({ label, ...rest }) => {
+ let displayLabel: React.ReactNode = label;
+
+ if (typeof label === 'string' || typeof label === 'number') {
+ const strLabel = String(displayLabel);
+
+ if (strLabel.length > maxTagTextLength) {
+ displayLabel = `${strLabel.slice(0, maxTagTextLength)}...`;
+ }
+ }
+
+ return {
+ ...rest,
+ label: displayLabel,
+ };
+ });
+ }
+
+ // Fill rest
+ if (restCount) {
+ displayValues.push({
+ key: REST_TAG_KEY,
+ label:
+ typeof maxTagPlaceholder === 'function'
+ ? maxTagPlaceholder(values.slice(maxTagCount))
+ : maxTagPlaceholder,
+ });
+ }
+
+ const selectionNode = (
+
+ {({ key, label, value, disabled: itemDisabled, className, style }) => {
+ const mergedKey = key || value;
+
+ return (
+
+ {label}
+ {key !== REST_TAG_KEY && !itemDisabled && (
+ {
+ event.preventDefault();
+ event.stopPropagation();
+ }}
+ onClick={event => {
+ event.stopPropagation();
+ onSelect(value, { selected: false });
+ }}
+ customizeIcon={removeIcon}
+ >
+ ×
+
+ )}
+
+ );
+ }}
+
+ );
+
+ return (
+ <>
+ {selectionNode}
+
+
+
+
+ {/* Measure Node */}
+
+ {searchValue}
+
+
+
+ {!values.length && !searchValue && (
+ {placeholder}
+ )}
+ >
+ );
+};
+
+export default SelectSelector;
diff --git a/src/Selector/SingleSelector.tsx b/src/Selector/SingleSelector.tsx
new file mode 100644
index 000000000..aae2c4c71
--- /dev/null
+++ b/src/Selector/SingleSelector.tsx
@@ -0,0 +1,74 @@
+import React from 'react';
+import Input from './Input';
+import { InnerSelectorProps } from '.';
+
+interface SelectorProps extends InnerSelectorProps {
+ inputElement: React.ReactElement;
+ activeValue: string;
+ backfill?: boolean;
+}
+
+const SingleSelector: React.FC = ({
+ inputElement,
+ prefixCls,
+ id,
+ inputRef,
+ disabled,
+ autoFocus,
+ accessibilityIndex,
+ mode,
+ open,
+ values,
+ placeholder,
+
+ showSearch,
+ searchValue,
+ activeValue,
+
+ onInputKeyDown,
+ onInputChange,
+}) => {
+ const combobox = mode === 'combobox';
+ const inputEditable = combobox || (showSearch && open);
+ const item = values[0];
+
+ let inputValue: string = searchValue;
+ if (combobox) {
+ inputValue = item ? String(item.value) : activeValue || searchValue;
+ }
+
+ const hasTextInput = !!inputValue;
+
+ return (
+ <>
+
+
+
+
+ {/* Display value */}
+ {!combobox && item && !hasTextInput && (
+ {item.label}
+ )}
+
+ {/* Display placeholder */}
+ {!item && !hasTextInput && (
+ {placeholder}
+ )}
+ >
+ );
+};
+
+export default SingleSelector;
diff --git a/src/Selector/index.tsx b/src/Selector/index.tsx
new file mode 100644
index 000000000..8c0219d44
--- /dev/null
+++ b/src/Selector/index.tsx
@@ -0,0 +1,160 @@
+/**
+ * Cursor rule:
+ * 1. Only `showSearch` enabled
+ * 2. Only `open` is `true`
+ * 3. When typing, set `open` to `true` which hit rule of 2
+ *
+ * Accessibility:
+ * - https://www.w3.org/TR/wai-aria-practices/examples/combobox/aria1.1pattern/listbox-combo.html
+ */
+
+import * as React from 'react';
+import KeyCode from 'rc-util/lib/KeyCode';
+import MultipleSelector from './MultipleSelector';
+import SingleSelector from './SingleSelector';
+import { LabelValueType, RawValueType } from '../interface/generator';
+import { RenderNode, Mode } from '../interface';
+
+export interface InnerSelectorProps {
+ prefixCls: string;
+ id: string;
+ mode: Mode;
+
+ inputRef: React.Ref;
+ placeholder?: React.ReactNode;
+ disabled?: boolean;
+ autoFocus?: boolean;
+ values: LabelValueType[];
+ showSearch?: boolean;
+ searchValue: string;
+ accessibilityIndex: number;
+ open: boolean;
+
+ onInputKeyDown: React.KeyboardEventHandler;
+ onInputChange: React.ChangeEventHandler;
+}
+
+export interface RefSelectorProps {
+ focus: () => void;
+ blur: () => void;
+}
+
+export interface SelectorProps {
+ id: string;
+ prefixCls: string;
+ showSearch?: boolean;
+ open: boolean;
+ /** Display in the Selector value, it's not same as `value` prop */
+ values: LabelValueType[];
+ multiple: boolean;
+ mode: Mode;
+ searchValue: string;
+ activeValue: string;
+ inputElement: JSX.Element;
+
+ autoFocus?: boolean;
+ accessibilityIndex: number;
+ disabled?: boolean;
+ placeholder?: React.ReactNode;
+ removeIcon?: RenderNode;
+
+ // Tags
+ maxTagCount?: number;
+ maxTagTextLength?: number;
+ maxTagPlaceholder?: (omittedValues: LabelValueType[]) => React.ReactNode;
+
+ // Motion
+ choiceTransitionName?: string;
+
+ onToggleOpen: (open?: boolean) => void;
+ /** `onSearch` returns go next step boolean to check if need do toggle open */
+ onSearch: (searchValue: string) => boolean;
+ onSelect: (value: RawValueType, option: { selected: boolean }) => void;
+ onInputKeyDown?: React.KeyboardEventHandler;
+}
+
+const Selector: React.RefForwardingComponent = (props, ref) => {
+ const inputRef = React.useRef(null);
+
+ const {
+ prefixCls,
+ multiple,
+
+ onSearch,
+ onToggleOpen,
+ onInputKeyDown,
+ } = props;
+
+ // ======================= Ref =======================
+ React.useImperativeHandle(ref, () => ({
+ focus: () => {
+ inputRef.current.focus();
+ },
+ blur: () => {
+ inputRef.current.blur();
+ },
+ }));
+
+ // ====================== Input ======================
+ const onInternalInputKeyDown: React.KeyboardEventHandler = event => {
+ const { which } = event;
+
+ if (which === KeyCode.UP || which === KeyCode.DOWN) {
+ event.preventDefault();
+ }
+
+ if (onInputKeyDown) {
+ onInputKeyDown(event);
+ }
+
+ if (![KeyCode.SHIFT, KeyCode.TAB, KeyCode.BACKSPACE, KeyCode.ESC].includes(which)) {
+ onToggleOpen(true);
+ }
+ };
+
+ const onInputChange = ({ target: { value } }) => {
+ if (onSearch(value) !== false) {
+ onToggleOpen(true);
+ }
+ };
+
+ // ====================== Focus ======================
+ // Should focus input if click the selector
+ const onClick = ({ target }) => {
+ if (target !== inputRef.current) {
+ inputRef.current.focus();
+ }
+ };
+
+ const onMouseDown: React.MouseEventHandler = event => {
+ if (event.target !== inputRef.current) {
+ event.preventDefault();
+ }
+
+ onToggleOpen();
+ };
+
+ // ================= Inner Selector ==================
+ const sharedProps = {
+ inputRef,
+ onInputKeyDown: onInternalInputKeyDown,
+ onInputChange,
+ };
+
+ const selectNode = multiple ? (
+
+ ) : (
+
+ );
+
+ return (
+
+ {selectNode}
+
+ );
+};
+
+const ForwardSelector = React.forwardRef(Selector);
+ForwardSelector.displayName = 'Selector';
+
+export default ForwardSelector;
diff --git a/src/TransBtn.tsx b/src/TransBtn.tsx
new file mode 100644
index 000000000..04138bee7
--- /dev/null
+++ b/src/TransBtn.tsx
@@ -0,0 +1,55 @@
+import * as React from 'react';
+import classNames from 'classnames';
+
+export interface TransBtnProps {
+ className: string;
+ customizeIcon: React.ReactNode;
+ customizeIconProps?: { isSelected: boolean };
+ onMouseDown?: React.MouseEventHandler;
+ onClick?: React.MouseEventHandler;
+ children?: React.ReactNode;
+}
+
+const TransBtn: React.FC = ({
+ className,
+ customizeIcon,
+ customizeIconProps,
+ onMouseDown,
+ onClick,
+ children,
+}) => {
+ let icon: React.ReactNode;
+
+ if (typeof customizeIcon === 'function') {
+ icon = customizeIcon(customizeIconProps);
+ } else {
+ icon = customizeIcon;
+ }
+
+ return (
+ {
+ event.preventDefault();
+ if (onMouseDown) {
+ onMouseDown(event);
+ }
+ }}
+ style={{
+ userSelect: 'none',
+ WebkitUserSelect: 'none',
+ }}
+ unselectable="on"
+ onClick={onClick}
+ aria-hidden
+ >
+ {icon || (
+ `${cls}-icon`))}>
+ {children}
+
+ )}
+
+ );
+};
+
+export default TransBtn;
diff --git a/src/generate.tsx b/src/generate.tsx
new file mode 100644
index 000000000..1ada38c46
--- /dev/null
+++ b/src/generate.tsx
@@ -0,0 +1,863 @@
+/**
+ * To match accessibility requirement, we always provide an input in the component.
+ * Other element will not set `tabIndex` to avoid `onBlur` sequence problem.
+ * For focused select, we set `aria-live="polite"` to update the accessibility content.
+ *
+ * ref:
+ * - keyboard: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role#Keyboard_interactions
+ */
+
+import * as React from 'react';
+import KeyCode from 'rc-util/lib/KeyCode';
+import classNames from 'classnames';
+import Selector, { RefSelectorProps } from './Selector';
+import SelectTrigger, { RefTriggerProps } from './SelectTrigger';
+import { RenderNode, Mode } from './interface';
+import {
+ GetLabeledValue,
+ FilterOptions,
+ FilterFunc,
+ DefaultValueType,
+ RawValueType,
+ LabelValueType,
+ Key,
+ RefSelectFunc,
+ DisplayLabelValueType,
+ FlattenOptionsType,
+ SingleType,
+} from './interface/generator';
+import { OptionListProps, RefOptionListProps } from './OptionList';
+import { toInnerValue, toOuterValues, removeLastEnabledValue, getUUID } from './utils/commonUtil';
+import TransBtn from './TransBtn';
+import { useLock } from './hooks/useLock';
+import useDelayReset from './hooks/useDelayReset';
+import useLayoutEffect from './hooks/useLayoutEffect';
+import { getSeparatedContent } from './utils/valueUtil';
+
+export interface RefSelectProps {
+ focus: () => void;
+ blur: () => void;
+}
+
+export interface SelectProps extends React.AriaAttributes {
+ prefixCls?: string;
+ id?: string;
+ className?: string;
+ style?: React.CSSProperties;
+
+ // Options
+ options?: OptionsType;
+ children?: React.ReactNode;
+ mode?: Mode;
+
+ // Value
+ value?: ValueType;
+ defaultValue?: ValueType;
+ labelInValue?: boolean;
+
+ // Search
+ inputValue?: string;
+ searchValue?: string;
+ optionFilterProp?: string;
+ /**
+ * In Select, `false` means do nothing.
+ * In TreeSelect, `false` will highlight match item.
+ * It's by design.
+ */
+ filterOption?: boolean | FilterFunc;
+ showSearch?: boolean;
+ autoClearSearchValue?: boolean;
+ onSearch?: (value: string) => void;
+
+ // Icons
+ allowClear?: boolean;
+ clearIcon?: React.ReactNode;
+ showArrow?: boolean;
+ inputIcon?: React.ReactNode;
+ removeIcon?: React.ReactNode;
+ menuItemSelectedIcon?: RenderNode;
+
+ // Dropdown
+ open?: boolean;
+ defaultOpen?: boolean;
+ listHeight?: number;
+ listItemHeight?: number;
+ dropdownStyle?: React.CSSProperties;
+ dropdownClassName?: string;
+ dropdownMatchSelectWidth?: true | number;
+ dropdownRender?: (menu: React.ReactElement) => React.ReactElement;
+ dropdownAlign?: any;
+ animation?: string;
+ transitionName?: string;
+ getPopupContainer?: RenderNode;
+
+ // Others
+ disabled?: boolean;
+ loading?: boolean;
+ autoFocus?: boolean;
+ defaultActiveFirstOption?: boolean;
+ notFoundContent?: React.ReactNode;
+ placeholder?: React.ReactNode;
+ backfill?: boolean;
+ getInputElement?: () => JSX.Element;
+ optionLabelProp?: string;
+ maxTagTextLength?: number;
+ maxTagCount?: number;
+ maxTagPlaceholder?: (omittedValues: LabelValueType[]) => React.ReactNode;
+ tokenSeparators?: string[];
+ showAction?: ('focus' | 'click')[];
+ tabIndex?: number;
+
+ // Events
+ onKeyUp?: React.KeyboardEventHandler;
+ onKeyDown?: React.KeyboardEventHandler;
+ onPopupScroll?: React.UIEventHandler;
+ onDropdownVisibleChange?: (open: boolean) => void;
+ onSelect?: (value: SingleType, option: OptionsType[number]) => void;
+ onDeselect?: (value: SingleType, option: OptionsType[number]) => void;
+ onInputKeyDown?: React.KeyboardEventHandler;
+ onClick?: React.MouseEventHandler;
+ onChange?: (value: ValueType, option: OptionsType[number] | OptionsType) => void;
+ onBlur?: React.FocusEventHandler;
+ onFocus?: React.FocusEventHandler;
+ onMouseDown?: React.MouseEventHandler;
+ onMouseEnter?: React.MouseEventHandler;
+ onMouseLeave?: React.MouseEventHandler;
+
+ // Motion
+ choiceTransitionName?: string;
+}
+
+export interface GenerateConfig {
+ prefixCls: string;
+ components: {
+ optionList: React.ForwardRefExoticComponent<
+ React.PropsWithoutRef<
+ Omit, 'options'> & { options: OptionsType }
+ > &
+ React.RefAttributes
+ >;
+ };
+ staticProps?: StaticProps;
+ /** Convert jsx tree into `OptionsType` */
+ convertChildrenToData: (children: React.ReactNode) => OptionsType;
+ /** Flatten nest options into raw option list */
+ flattenOptions: (options: OptionsType) => FlattenOptionsType;
+ /** Convert single raw value into { label, value } format. Will be called by each value */
+ getLabeledValue: GetLabeledValue>;
+ filterOptions: FilterOptions;
+ findValueOption: (
+ values: RawValueType[],
+ options: FlattenOptionsType,
+ ) => OptionsType;
+ /** Check if a value is disabled */
+ isValueDisabled: (value: RawValueType, options: FlattenOptionsType) => boolean;
+ warningProps: (props: SelectProps) => void;
+ fillOptionsWithMissingValue?: (
+ options: OptionsType,
+ value: DefaultValueType,
+ optionLabelProp: string,
+ labelInValue: boolean,
+ ) => OptionsType;
+}
+
+/**
+ * This function is in internal usage.
+ * Do not use it in your prod env since we may refactor this.
+ */
+export default function generateSelector<
+ OptionsType extends {
+ value?: RawValueType;
+ label?: React.ReactNode;
+ key?: Key;
+ disabled?: boolean;
+ }[],
+ StaticProps
+>(config: GenerateConfig) {
+ const {
+ prefixCls: defaultPrefixCls,
+ components: { optionList: OptionList },
+ staticProps,
+ convertChildrenToData,
+ flattenOptions,
+ getLabeledValue,
+ filterOptions,
+ isValueDisabled,
+ findValueOption,
+ warningProps,
+ fillOptionsWithMissingValue,
+ } = config;
+
+ // Use raw define since `React.FC` not support generic
+ function Select(
+ props: SelectProps,
+ ref: React.Ref,
+ ): React.ReactElement {
+ const {
+ prefixCls = defaultPrefixCls,
+ className,
+ id,
+
+ open,
+ defaultOpen,
+ options,
+ children,
+
+ mode,
+ value,
+ defaultValue,
+ labelInValue,
+
+ // Search related
+ showSearch,
+ inputValue,
+ searchValue,
+ filterOption,
+ optionFilterProp = 'value',
+ autoClearSearchValue = true,
+ onSearch,
+
+ // Icons
+ allowClear,
+ clearIcon,
+ showArrow,
+ inputIcon,
+ removeIcon,
+ menuItemSelectedIcon,
+
+ // Others
+ disabled,
+ loading,
+ defaultActiveFirstOption,
+ notFoundContent = 'Not Found',
+ optionLabelProp,
+ placeholder,
+ backfill,
+ getInputElement,
+ getPopupContainer,
+ autoFocus,
+
+ // Dropdown
+ listHeight = 200,
+ listItemHeight = 20,
+ animation,
+ transitionName,
+ dropdownStyle,
+ dropdownClassName,
+ dropdownMatchSelectWidth,
+ dropdownRender,
+ dropdownAlign,
+ showAction = [],
+
+ // Tags
+ maxTagCount,
+ maxTagTextLength,
+ maxTagPlaceholder,
+ tokenSeparators,
+
+ // Motion
+ choiceTransitionName,
+
+ // Events
+ onPopupScroll,
+ onDropdownVisibleChange,
+ onInputKeyDown,
+ onFocus,
+ onBlur,
+ onKeyUp,
+ onKeyDown,
+ onMouseDown,
+
+ onChange,
+ onSelect,
+ onDeselect,
+
+ ...domProps
+ } = props;
+
+ const selectorRef = React.useRef(null);
+ const listRef = React.useRef(null);
+
+ /** Used for component focused management */
+ const [mockFocused, setMockFocused, cancelSetMockFocused] = useDelayReset();
+
+ // Inner id for accessibility usage. Only work in client side
+ const [innerId, setInnerId] = React.useState();
+ React.useEffect(() => {
+ setInnerId(`rc_select_${getUUID()}`);
+ }, []);
+ const mergedId = id || innerId;
+
+ // optionLabelProp
+ let mergedOptionLabelProp = optionLabelProp;
+ if (mergedOptionLabelProp === undefined) {
+ mergedOptionLabelProp = options ? 'label' : 'children';
+ }
+
+ // labelInValue
+ const mergedLabelInValue = mode === 'combobox' ? false : labelInValue;
+
+ const mergedShowSearch =
+ showSearch !== undefined ? showSearch : mode === 'tags' || mode === 'combobox';
+
+ // ============================== Ref ===============================
+ React.useImperativeHandle(ref, () => ({
+ focus: selectorRef.current.focus,
+ blur: selectorRef.current.blur,
+ }));
+
+ // ============================= Value ==============================
+ const [innerValue, setInnerValue] = React.useState(value || defaultValue);
+ const baseValue = value !== undefined && value !== null ? value : innerValue;
+
+ /** Unique raw values */
+ const mergedRawValue = React.useMemo(
+ () =>
+ toInnerValue(baseValue, {
+ labelInValue: mergedLabelInValue,
+ combobox: mode === 'combobox',
+ }),
+ [baseValue, mergedLabelInValue],
+ );
+ /** We cache a set of raw values to speed up check */
+ const rawValues = React.useMemo>(() => new Set(mergedRawValue), [
+ mergedRawValue,
+ ]);
+
+ // ============================= Option =============================
+ // Set by option list active, it will merge into search input when mode is `combobox`
+ const [activeValue, setActiveValue] = React.useState(null);
+ const [innerSearchValue, setInnerSearchValue] = React.useState('');
+ let mergedSearchValue = innerSearchValue;
+ if (searchValue !== undefined) {
+ mergedSearchValue = searchValue;
+ } else if (inputValue) {
+ mergedSearchValue = inputValue;
+ }
+
+ const mergedOptions = React.useMemo((): OptionsType => {
+ let newOptions = options;
+ if (newOptions === undefined) {
+ newOptions = convertChildrenToData(children);
+ }
+
+ /**
+ * `tags` should fill un-list item.
+ * This is not cool here since TreeSelect do not need this
+ */
+ if (mode === 'tags' && fillOptionsWithMissingValue) {
+ newOptions = fillOptionsWithMissingValue(
+ newOptions,
+ baseValue,
+ mergedOptionLabelProp,
+ labelInValue,
+ );
+ }
+
+ return newOptions;
+ }, [options, children, mode, baseValue]);
+
+ const mergedFlattenOptions: FlattenOptionsType = React.useMemo(
+ () => flattenOptions(mergedOptions),
+ [mergedOptions],
+ );
+
+ // Display options for OptionList
+ const displayOptions = React.useMemo(() => {
+ if (!mergedSearchValue) {
+ return [...mergedOptions] as OptionsType;
+ }
+ const filteredOptions: OptionsType = filterOptions(mergedSearchValue, mergedOptions, {
+ optionFilterProp,
+ filterOption: mode === 'combobox' && filterOption === undefined ? () => true : filterOption,
+ });
+ if (mode === 'tags' && filteredOptions.every(opt => opt.value !== mergedSearchValue)) {
+ filteredOptions.unshift({
+ value: mergedSearchValue,
+ label: mergedSearchValue,
+ key: '__RC_SELECT_TAG_PLACEHOLDER__',
+ });
+ }
+
+ return filteredOptions;
+ }, [mergedOptions, mergedSearchValue, mode]);
+
+ const displayFlattenOptions: FlattenOptionsType = React.useMemo(
+ () => flattenOptions(displayOptions),
+ [displayOptions],
+ );
+
+ // ============================ Selector ============================
+ const displayValues = React.useMemo(
+ () =>
+ mergedRawValue.map((val: RawValueType) => {
+ const displayValue = getLabeledValue(val, {
+ options: mergedFlattenOptions,
+ prevValue: baseValue,
+ labelInValue: mergedLabelInValue,
+ optionLabelProp: mergedOptionLabelProp,
+ });
+
+ return {
+ ...displayValue,
+ disabled: isValueDisabled(val, mergedFlattenOptions),
+ };
+ }),
+ [baseValue, mergedOptions],
+ );
+
+ const isMultiple = mode === 'tags' || mode === 'multiple';
+
+ const triggerSelect = (newValue: RawValueType, isSelect: boolean) => {
+ const selectValue = (mergedLabelInValue
+ ? getLabeledValue(newValue, {
+ options: mergedFlattenOptions,
+ prevValue: baseValue,
+ labelInValue: mergedLabelInValue,
+ optionLabelProp: mergedOptionLabelProp,
+ })
+ : newValue) as SingleType;
+
+ const outOption = findValueOption([newValue], mergedFlattenOptions)[0];
+
+ if (isSelect && onSelect) {
+ onSelect(selectValue, outOption);
+ } else if (!isSelect && onDeselect) {
+ onDeselect(selectValue, outOption);
+ }
+ };
+
+ const triggerChange = (newRawValues: RawValueType[]) => {
+ const outValues = toOuterValues>(Array.from(newRawValues), {
+ labelInValue: mergedLabelInValue,
+ options: mergedFlattenOptions,
+ getLabeledValue,
+ prevValue: baseValue,
+ optionLabelProp: mergedOptionLabelProp,
+ });
+
+ const outValue: ValueType = (isMultiple ? outValues : outValues[0]) as ValueType;
+ // Skip trigger if prev & current value is both empty
+ if (onChange && (mergedRawValue.length !== 0 || outValues.length !== 0)) {
+ const outOptions = findValueOption(newRawValues, mergedFlattenOptions);
+
+ onChange(outValue, isMultiple ? outOptions : outOptions[0]);
+ }
+
+ setInnerValue(outValue);
+ };
+
+ const onInternalSelect = (newValue: RawValueType, { selected }: { selected: boolean }) => {
+ if (disabled) {
+ return;
+ }
+
+ let newRawValue: Set;
+
+ if (isMultiple) {
+ newRawValue = new Set(mergedRawValue);
+ if (selected) {
+ newRawValue.add(newValue);
+ } else {
+ newRawValue.delete(newValue);
+ }
+ } else {
+ newRawValue = new Set();
+ newRawValue.add(newValue);
+ }
+
+ // Multiple always trigger change and single should change if value changed
+ if (isMultiple || (!isMultiple && Array.from(mergedRawValue)[0] !== newValue)) {
+ triggerChange(Array.from(newRawValue));
+ }
+
+ // Trigger `onSelect`. Single mode always trigger select
+ triggerSelect(newValue, !isMultiple || selected);
+
+ // Clean search value if single or configured
+ if (mode === 'combobox') {
+ setInnerSearchValue(String(newValue));
+ setActiveValue('');
+ } else if (!isMultiple || autoClearSearchValue) {
+ setInnerSearchValue('');
+ setActiveValue('');
+ }
+ };
+
+ // ============================= Input ==============================
+ // Only works in `combobox`
+ const customizeInputElement: React.ReactElement =
+ (mode === 'combobox' && getInputElement && getInputElement()) || null;
+
+ // ============================== Open ==============================
+ const [innerOpen, setInnerOpen] = React.useState(defaultOpen);
+ let mergedOpen: boolean = open !== undefined ? open : innerOpen;
+
+ // Not trigger `open` in `combobox` when `notFoundContent` is empty
+ if (mergedOpen && mode === 'combobox' && !notFoundContent && !displayOptions.length) {
+ mergedOpen = false;
+ }
+
+ const onToggleOpen = (newOpen?: boolean) => {
+ const nextOpen = newOpen !== undefined ? newOpen : !mergedOpen;
+
+ if (mergedOpen !== nextOpen && !disabled) {
+ setInnerOpen(nextOpen);
+
+ if (onDropdownVisibleChange) {
+ onDropdownVisibleChange(nextOpen);
+ }
+ }
+ };
+
+ // ============================= Search =============================
+ const triggerSearch = (searchText: string, fromTyping: boolean = true) => {
+ let ret = true;
+ let newSearchText = searchText;
+ setActiveValue(null);
+
+ // Check if match the `tokenSeparators`
+ const patchLabels: string[] = getSeparatedContent(searchText, tokenSeparators);
+ let patchRawValues: RawValueType[] = patchLabels;
+
+ if (mode === 'combobox') {
+ // Only typing will trigger onChange
+ if (fromTyping) {
+ triggerChange([newSearchText]);
+ }
+ } else if (patchLabels) {
+ newSearchText = '';
+
+ if (mode !== 'tags') {
+ patchRawValues = patchLabels
+ .map(label => {
+ const item = mergedFlattenOptions.find(
+ ({ data }) => data[mergedOptionLabelProp] === label,
+ );
+ return item ? item.data.value : null;
+ })
+ .filter((val: RawValueType) => val !== null);
+ }
+
+ const newRawValues = Array.from(
+ new Set([...mergedRawValue, ...patchRawValues]),
+ );
+ triggerChange(newRawValues);
+ newRawValues.forEach(newRawValue => {
+ triggerSelect(newRawValue, true);
+ });
+
+ // Should close when paste finish
+ onToggleOpen(false);
+
+ // Tell Selector that break next actions
+ ret = false;
+ }
+
+ setInnerSearchValue(newSearchText);
+
+ if (onSearch && mergedSearchValue !== newSearchText) {
+ onSearch(newSearchText);
+ }
+
+ return ret;
+ };
+
+ // Close will clean up single mode search text
+ React.useEffect(() => {
+ if (!mergedOpen && !isMultiple && mode !== 'combobox') {
+ triggerSearch('', false);
+ }
+ }, [mergedOpen]);
+
+ // ============================ Keyboard ============================
+ /**
+ * We record input value here to check if can press to clean up by backspace
+ * - null: Key is not down, this is reset by key up
+ * - true: Search text is empty when first time backspace down
+ * - false: Search text is not empty when first time backspace down
+ */
+ const [clearLock, setClearLock] = useLock();
+
+ // KeyDown
+ const onInternalKeyDown: React.KeyboardEventHandler = (event, ...rest) => {
+ const { which } = event;
+
+ // We only manage open state here, close logic should handle by list component
+ if (!mergedOpen && which === KeyCode.ENTER) {
+ onToggleOpen(true);
+ }
+
+ setClearLock(!!mergedSearchValue);
+
+ // Remove value by `backspace`
+ if (
+ which === KeyCode.BACKSPACE &&
+ !clearLock &&
+ isMultiple &&
+ !mergedSearchValue &&
+ mergedRawValue.length
+ ) {
+ triggerChange(removeLastEnabledValue(displayValues, mergedRawValue));
+ }
+
+ if (mergedOpen && listRef.current) {
+ listRef.current.onKeyDown(event, ...rest);
+ }
+
+ if (onKeyDown) {
+ onKeyDown(event, ...rest);
+ }
+ };
+
+ // KeyUp
+ const onInternalKeyUp: React.KeyboardEventHandler = (event, ...rest) => {
+ if (mergedOpen && listRef.current) {
+ listRef.current.onKeyUp(event, ...rest);
+ }
+
+ if (onKeyUp) {
+ onKeyUp(event, ...rest);
+ }
+ };
+
+ // ========================== Focus / Blur ==========================
+ const triggerRef = React.useRef(null);
+ /** Record real focus status */
+ const focusRef = React.useRef(false);
+
+ const onContainerFocus: React.FocusEventHandler = (...args) => {
+ setMockFocused(true);
+
+ if (!disabled) {
+ if (onFocus && !focusRef.current) {
+ onFocus(...args);
+ }
+
+ // `showAction` should handle `focus` if set
+ if (showAction.includes('focus')) {
+ onToggleOpen(true);
+ }
+ }
+
+ focusRef.current = true;
+ };
+
+ const onContainerBlur: React.FocusEventHandler = (...args) => {
+ setMockFocused(false, () => {
+ focusRef.current = false;
+ onToggleOpen(false);
+ });
+
+ if (disabled) {
+ return;
+ }
+
+ // `tags` mode should move `searchValue` into values
+ if (mode === 'tags' && mergedSearchValue) {
+ triggerSearch('', false);
+ triggerChange(Array.from(new Set([...mergedRawValue, mergedSearchValue])));
+ }
+
+ if (onBlur) {
+ onBlur(...args);
+ }
+ };
+
+ const onInternalMouseDown: React.MouseEventHandler = (event, ...restArgs) => {
+ const { target } = event;
+ const popupElement: HTMLDivElement =
+ triggerRef.current && triggerRef.current.getPopupElement();
+
+ // We should give focus back to selector if clicked item is not focusable
+ if (popupElement && popupElement.contains(target as HTMLElement)) {
+ setTimeout(() => {
+ cancelSetMockFocused();
+ if (!popupElement.contains(document.activeElement)) {
+ selectorRef.current.focus();
+ }
+ });
+ }
+
+ if (onMouseDown) {
+ onMouseDown(event, ...restArgs);
+ }
+ };
+
+ // ========================= Accessibility ==========================
+ const [accessibilityIndex, setAccessibilityIndex] = React.useState(0);
+ const mergedDefaultActiveFirstOption =
+ defaultActiveFirstOption !== undefined ? defaultActiveFirstOption : mode !== 'combobox';
+
+ const onActiveValue = (active: RawValueType, index: number) => {
+ setAccessibilityIndex(index);
+
+ if (backfill && mode === 'combobox' && active !== null) {
+ setActiveValue(String(active));
+ }
+ };
+
+ // ============================= Popup ==============================
+ const containerRef = React.useRef(null);
+ const [containerWidth, setContainerWidth] = React.useState(null);
+
+ useLayoutEffect(() => {
+ const newWidth = Math.ceil(containerRef.current.offsetWidth);
+ if (containerWidth !== newWidth) {
+ setContainerWidth(newWidth);
+ }
+ });
+
+ const popupNode = (
+
+ );
+
+ // ============================= Clear ==============================
+ let clearNode: React.ReactNode;
+ const onClearMouseDown: React.MouseEventHandler = () => {
+ triggerChange([]);
+ triggerSearch('', false);
+ };
+
+ if (allowClear && (mergedRawValue.length || mergedSearchValue)) {
+ clearNode = (
+
+ ×
+
+ );
+ }
+
+ // ============================= Arrow ==============================
+ const mergedShowArrow =
+ showArrow !== undefined ? showArrow : loading || (!isMultiple && mode !== 'combobox');
+ let arrowNode: React.ReactNode;
+
+ if (mergedShowArrow) {
+ arrowNode = (
+
+ );
+ }
+
+ // ============================ Warning =============================
+ if (process.env.NODE_ENV !== 'production') {
+ warningProps(props);
+ }
+
+ // ============================= Render =============================
+ const mergedClassName = classNames(prefixCls, className, {
+ [`${prefixCls}-focused`]: mockFocused,
+ [`${prefixCls}-multiple`]: isMultiple,
+ [`${prefixCls}-single`]: !isMultiple,
+ [`${prefixCls}-allow-clear`]: allowClear,
+ [`${prefixCls}-show-arrow`]: mergedShowArrow,
+ [`${prefixCls}-disabled`]: disabled,
+ [`${prefixCls}-loading`]: loading,
+ [`${prefixCls}-open`]: mergedOpen,
+ [`${prefixCls}-customize-input`]: customizeInputElement,
+ });
+
+ return (
+
+ {mockFocused && !mergedOpen && (
+
+ {/* Merge into one string to make screen reader work as expect */}
+ {`${mergedRawValue.join(', ')}`}
+
+ )}
+
+
+
+
+ {arrowNode}
+ {clearNode}
+
+ );
+ }
+
+ // Ref of Select
+ type RefSelectFuncType = typeof RefSelectFunc;
+ const RefSelect = ((React.forwardRef as unknown) as RefSelectFuncType)(Select);
+
+ // Inject static props
+ if (staticProps) {
+ Object.keys(staticProps).forEach(prop => {
+ RefSelect[prop] = staticProps[prop];
+ });
+ }
+
+ return RefSelect as (typeof RefSelect & StaticProps);
+}
diff --git a/src/hooks/useDelayReset.ts b/src/hooks/useDelayReset.ts
new file mode 100644
index 000000000..c9448a2da
--- /dev/null
+++ b/src/hooks/useDelayReset.ts
@@ -0,0 +1,31 @@
+import * as React from 'react';
+
+/**
+ * Similar with `useLock`, but this hook will always execute last value.
+ * When set to `true`, it will keep `true` for a short time even if `false` is set.
+ */
+export default function useDelayReset(
+ timeout: number = 10,
+): [boolean, (val: boolean, callback?: () => void) => void, () => void] {
+ const [bool, setBool] = React.useState(false);
+ const delayRef = React.useRef(null);
+
+ const cancelLatest = () => {
+ window.clearTimeout(delayRef.current);
+ };
+
+ React.useEffect(cancelLatest, []);
+
+ const delaySetBool = (value: boolean, callback: () => void) => {
+ cancelLatest();
+
+ delayRef.current = window.setTimeout(() => {
+ setBool(value);
+ if (callback) {
+ callback();
+ }
+ }, timeout);
+ };
+
+ return [bool, delaySetBool, cancelLatest];
+}
diff --git a/src/hooks/useLayoutEffect.ts b/src/hooks/useLayoutEffect.ts
new file mode 100644
index 000000000..fab070cda
--- /dev/null
+++ b/src/hooks/useLayoutEffect.ts
@@ -0,0 +1,17 @@
+/* eslint-disable react-hooks/rules-of-hooks */
+import * as React from 'react';
+import { isBrowserClient } from '../utils/commonUtil';
+
+/**
+ * Wrap `React.useLayoutEffect` which will not throw warning message in test env
+ */
+export default function useLayoutEffect(effect: React.EffectCallback, deps?: React.DependencyList) {
+ // Never happen in test env
+ if (isBrowserClient) {
+ /* istanbul ignore next */
+ React.useLayoutEffect(effect, deps);
+ } else {
+ React.useEffect(effect, deps);
+ }
+}
+/* eslint-enable */
diff --git a/src/hooks/useLock.ts b/src/hooks/useLock.ts
new file mode 100644
index 000000000..15f2cec4c
--- /dev/null
+++ b/src/hooks/useLock.ts
@@ -0,0 +1,33 @@
+import * as React from 'react';
+
+/**
+ * Locker return cached mark.
+ * If set to `true`, will return `true` in a short time even if set `false`.
+ * If set to `false` and then set to `true`, will change to `true`.
+ * And after time duration, it will back to `null` automatically.
+ */
+export function useLock(duration: number = 250): [boolean, (lock: boolean) => void] {
+ const [lock, setLock] = React.useState(null);
+ const lockRef = React.useRef(null);
+
+ // Clean up
+ React.useEffect(
+ () => () => {
+ window.clearTimeout(lockRef.current);
+ },
+ [],
+ );
+
+ function doLock(locked: boolean) {
+ if (locked || lock === null) {
+ setLock(locked);
+ }
+
+ window.clearTimeout(lockRef.current);
+ lockRef.current = window.setTimeout(() => {
+ setLock(null);
+ }, duration);
+ }
+
+ return [lock, doLock];
+}
diff --git a/src/index.ts b/src/index.ts
index 6b73757e1..d806cb552 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,9 +1,8 @@
-import OptGroup from './OptGroup';
+import Select, { SelectProps } from './Select';
+import { RefSelectProps } from './generate';
import Option from './Option';
-import SelectPropTypes from './PropTypes';
-import Select from './Select';
+import OptGroup from './OptGroup';
+
+export { Option, OptGroup, SelectProps, RefSelectProps };
-Select.Option = Option;
-Select.OptGroup = OptGroup;
-export { Option, OptGroup, SelectPropTypes };
export default Select;
diff --git a/src/interface/generator.ts b/src/interface/generator.ts
new file mode 100644
index 000000000..af2bf4e31
--- /dev/null
+++ b/src/interface/generator.ts
@@ -0,0 +1,52 @@
+import { SelectProps, RefSelectProps } from '../generate';
+
+// =================================== Shared Type ===================================
+export type Key = string | number;
+
+export type RawValueType = string | number;
+
+export interface LabelValueType {
+ key?: Key;
+ value?: RawValueType;
+ label?: React.ReactNode;
+}
+export type DefaultValueType = RawValueType | RawValueType[] | LabelValueType | LabelValueType[];
+
+export interface DisplayLabelValueType extends LabelValueType {
+ disabled?: boolean;
+}
+
+export type SingleType = MixType extends (infer Single)[] ? Single : MixType;
+
+// ==================================== Generator ====================================
+export type GetLabeledValue = (
+ value: RawValueType,
+ config: {
+ options: FOT;
+ prevValue: DefaultValueType;
+ labelInValue: boolean;
+ optionLabelProp: string;
+ },
+) => LabelValueType;
+
+export type FilterOptions = (
+ searchValue: string,
+ options: OptionsType,
+ /** Component props, since Select & TreeSelect use different prop name, use any here */
+ config: { optionFilterProp: string; filterOption: boolean | FilterFunc },
+) => OptionsType;
+
+export type FilterFunc = (inputValue: string, option?: OptionType) => boolean;
+
+export declare function RefSelectFunc(
+ Component: React.RefForwardingComponent>,
+): React.ForwardRefExoticComponent<
+ React.PropsWithoutRef> & React.RefAttributes
+>;
+
+export type FlattenOptionsType = {
+ key: Key;
+ data: OptionsType[number];
+ /** Used for customize data */
+ [name: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
+}[];
diff --git a/src/interface/index.ts b/src/interface/index.ts
new file mode 100644
index 000000000..84df75fb9
--- /dev/null
+++ b/src/interface/index.ts
@@ -0,0 +1,42 @@
+import * as React from 'react';
+import { Key } from './generator';
+
+export type RenderNode = React.ReactNode | (() => React.ReactNode);
+
+export type Mode = 'multiple' | 'tags' | 'combobox';
+
+// ======================== Option ========================
+export interface OptionData {
+ key?: Key;
+ disabled?: boolean;
+ value: Key;
+ title?: string;
+ className?: string;
+ style?: React.CSSProperties;
+ label?: React.ReactNode;
+ /** @deprecated Only works when use `children` as option data */
+ children?: React.ReactNode;
+
+ /** Save for customize data */
+ [prop: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
+}
+
+export interface OptionGroupData {
+ key?: Key;
+ label?: React.ReactNode;
+ options: OptionData[];
+ className?: string;
+ style?: React.CSSProperties;
+
+ /** Save for customize data */
+ [prop: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
+}
+
+export type OptionsType = (OptionData | OptionGroupData)[];
+
+export interface FlattenOptionData {
+ group?: boolean;
+ groupOption?: boolean;
+ key: string | number;
+ data: OptionData | OptionGroupData;
+}
diff --git a/src/util.ts b/src/util.ts
deleted file mode 100644
index 147f97307..000000000
--- a/src/util.ts
+++ /dev/null
@@ -1,206 +0,0 @@
-import React, { ReactElement } from 'react';
-import Option from './Option';
-import { filterOptionType, IILableValueType, ISelectProps, valueType } from './PropTypes';
-
-export function toTitle(title: string): string {
- if (typeof title === 'string') {
- return title;
- }
- return '';
-}
-
-export function getValuePropValue(child: any) {
- if (!child) {
- return null;
- }
-
- const props = child.props;
- if ('value' in props) {
- return props.value;
- }
- if (child.key) {
- return child.key;
- }
- if (child.type && child.type.isSelectOptGroup && props.label) {
- return props.label;
- }
- throw new Error(`Need at least a key or a value or a label (only for OptGroup) for ${child}`);
-}
-
-export function getPropValue(child: Option, prop?: any) {
- if (prop === 'value') {
- return getValuePropValue(child);
- }
- return child.props[prop];
-}
-
-export function isMultiple(props: Partial) {
- return props.multiple;
-}
-
-export function isCombobox(props: Partial) {
- return props.combobox;
-}
-
-export function isMultipleOrTags(props: Partial) {
- return props.multiple || props.tags;
-}
-
-export function isMultipleOrTagsOrCombobox(props: Partial) {
- return isMultipleOrTags(props) || isCombobox(props);
-}
-
-export function isSingleMode(props: any) {
- return !isMultipleOrTagsOrCombobox(props);
-}
-
-export function toArray(value: valueType | undefined): valueType | undefined {
- let ret = value;
- if (value === undefined) {
- ret = [];
- } else if (!Array.isArray(value)) {
- ret = [value as string];
- }
- return ret;
-}
-
-export function getMapKey(value: valueType) {
- return `${typeof value}-${value}`;
-}
-
-export function preventDefaultEvent(e: any) {
- e.preventDefault();
-}
-
-export function findIndexInValueBySingleValue(value: valueType | undefined, singleValue: string) {
- let index = -1;
-
- if (value) {
- for (let i = 0; i < (value as string[]).length; i++) {
- if (value[i] === singleValue) {
- index = i;
- break;
- }
- }
- }
-
- return index;
-}
-
-export function getLabelFromPropsValue(value: valueType | undefined, key: valueType) {
- let label;
- value = toArray(value);
- if (value) {
- // tslint:disable-next-line:prefer-for-of
- for (let i = 0; i < (value as string[]).length; i++) {
- if ((value as IILableValueType[])[i].key === key) {
- label = value[i].label;
- break;
- }
- }
- }
- return label;
-}
-
-export function getSelectKeys(menuItems: JSX.Element[], value?: string) {
- if (value === null || value === undefined) {
- return [];
- }
- let selectedKeys: string[] = [];
- React.Children.forEach(menuItems, item => {
- const type = (item as ReactElement).type as any;
- if (type.isMenuItemGroup) {
- selectedKeys = selectedKeys.concat(
- getSelectKeys((item as ReactElement).props.children, value),
- );
- } else {
- const itemValue = getValuePropValue(item);
- const itemKey = (item as ReactElement).key as string;
- if (findIndexInValueBySingleValue(value, itemValue) !== -1 && itemKey) {
- selectedKeys.push(itemKey);
- }
- }
- });
- return selectedKeys;
-}
-
-export const UNSELECTABLE_STYLE: any = {
- userSelect: 'none',
- WebkitUserSelect: 'none',
-};
-
-export const UNSELECTABLE_ATTRIBUTE: any = {
- unselectable: 'on',
-};
-
-export function findFirstMenuItem(children: JSX.Element[]): JSX.Element | null {
- // tslint:disable-next-line:prefer-for-of
- for (let i = 0; i < children.length; i++) {
- const child = children[i];
- if ((child.type as any).isMenuItemGroup) {
- const found = findFirstMenuItem(child.props.children);
- if (found) {
- return found;
- }
- } else if (!child.props.disabled) {
- return child;
- }
- }
- return null;
-}
-
-export function includesSeparators(str: string | string[], separators: string[]) {
- // tslint:disable-next-line:prefer-for-of
- for (let i = 0; i < separators.length; ++i) {
- if (str.lastIndexOf(separators[i]) > 0) {
- return true;
- }
- }
- return false;
-}
-
-export function splitBySeparators(str: string | string[], separators: string[]) {
- const reg = new RegExp(`[${separators.join()}]`);
- return (str as string).split(reg).filter(token => token);
-}
-
-export function defaultFilterFn(input: string, child: any): filterOptionType | boolean {
- if (child.props.disabled) {
- return false;
- }
- const value = (toArray(getPropValue(child, this.props.optionFilterProp)) as string[]).join('');
- return value.toLowerCase().indexOf(input.toLowerCase()) > -1;
-}
-
-export function validateOptionValue(value: string, props: any) {
- if (isSingleMode(props) || isMultiple(props)) {
- return;
- }
- if (typeof value !== 'string') {
- throw new Error(
- `Invalid \`value\` of type \`${typeof value}\` supplied to Option, ` +
- `expected \`string\` when \`tags/combobox\` is \`true\`.`,
- );
- }
-}
-
-export function saveRef(instance: any, name: string): (node: any) => void {
- return (node: JSX.Element) => {
- instance[name] = node;
- };
-}
-
-export function generateUUID(): string {
- if (process.env.NODE_ENV === 'test') {
- return 'test-uuid';
- }
- let d = new Date().getTime();
- const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
- // tslint:disable-next-line:no-bitwise
- const r = (d + Math.random() * 16) % 16 | 0;
- d = Math.floor(d / 16);
- // tslint:disable-next-line:no-bitwise
- return (c === 'x' ? r : (r & 0x7) | 0x8).toString(16);
- });
- return uuid;
-}
diff --git a/src/utils/commonUtil.ts b/src/utils/commonUtil.ts
new file mode 100644
index 000000000..6b609938e
--- /dev/null
+++ b/src/utils/commonUtil.ts
@@ -0,0 +1,107 @@
+import {
+ RawValueType,
+ GetLabeledValue,
+ LabelValueType,
+ DefaultValueType,
+ FlattenOptionsType,
+} from '../interface/generator';
+
+export function toArray(value: T | T[]): T[] {
+ if (Array.isArray(value)) {
+ return value;
+ }
+ return value !== undefined ? [value] : [];
+}
+
+/**
+ * Convert outer props value into internal value
+ */
+export function toInnerValue(
+ value: DefaultValueType,
+ { labelInValue, combobox }: { labelInValue: boolean; combobox: boolean },
+): RawValueType[] {
+ if (value === undefined || value === null || (value === '' && combobox)) {
+ return [];
+ }
+
+ const values = Array.isArray(value) ? value : [value];
+
+ if (labelInValue) {
+ return values.map(({ key, value: val }: LabelValueType) => (val !== undefined ? val : key));
+ }
+
+ return values as RawValueType[];
+}
+
+/**
+ * Convert internal value into out event value
+ */
+export function toOuterValues(
+ valueList: RawValueType[],
+ {
+ optionLabelProp,
+ labelInValue,
+ prevValue,
+ options,
+ getLabeledValue,
+ }: {
+ optionLabelProp: string;
+ labelInValue: boolean;
+ getLabeledValue: GetLabeledValue;
+ options: FOT;
+ prevValue: DefaultValueType;
+ },
+): RawValueType[] | LabelValueType[] {
+ let values: DefaultValueType = valueList;
+
+ if (labelInValue) {
+ values = values.map(val =>
+ getLabeledValue(val, { options, prevValue, labelInValue, optionLabelProp }),
+ );
+ }
+
+ return values;
+}
+
+export function removeLastEnabledValue<
+ T extends { disabled?: boolean },
+ P extends RawValueType | object
+>(measureValues: T[], values: P[]): P[] {
+ const newValues = [...values];
+
+ let removeIndex: number;
+ for (removeIndex = measureValues.length - 1; removeIndex >= 0; removeIndex -= 1) {
+ if (!measureValues[removeIndex].disabled) {
+ break;
+ }
+ }
+
+ if (removeIndex !== -1) {
+ newValues.splice(removeIndex, 1);
+ }
+
+ return newValues;
+}
+
+export const isClient =
+ typeof window !== 'undefined' && window.document && window.document.documentElement;
+
+/** Is client side and not jsdom */
+export const isBrowserClient = process.env.NODE_ENV !== 'test' && isClient;
+
+let uuid = 0;
+/** Get unique id for accessibility usage */
+export function getUUID(): number | string {
+ let retId: string | number;
+
+ // Test never reach
+ /* istanbul ignore if */
+ if (isBrowserClient) {
+ retId = uuid;
+ uuid += 1;
+ } else {
+ retId = 'TEST_OR_SSR';
+ }
+
+ return retId;
+}
diff --git a/src/utils/legacyUtil.ts b/src/utils/legacyUtil.ts
new file mode 100644
index 000000000..211aff3f0
--- /dev/null
+++ b/src/utils/legacyUtil.ts
@@ -0,0 +1,43 @@
+import * as React from 'react';
+import toArray from 'rc-util/lib/Children/toArray';
+import { OptionData, OptionGroupData, OptionsType } from '../interface';
+
+function convertNodeToOption(node: React.ReactElement): OptionData {
+ const {
+ key,
+ props: { children, value, ...restProps },
+ } = node as React.ReactElement;
+
+ return { key, value: value !== undefined ? value : key, children, ...restProps };
+}
+
+export function convertChildrenToData(
+ nodes: React.ReactNode,
+ optionOnly: boolean = false,
+): OptionsType {
+ return toArray(nodes)
+ .map((node: React.ReactElement): OptionData | OptionGroupData | null => {
+ if (!React.isValidElement(node) || !node.type) {
+ return null;
+ }
+
+ const {
+ type: { isSelectOptGroup },
+ key,
+ props: { children, ...restProps },
+ } = node as React.ReactElement & {
+ type: { isSelectOptGroup?: boolean };
+ };
+
+ if (optionOnly || !isSelectOptGroup) {
+ return convertNodeToOption(node);
+ }
+
+ return {
+ key,
+ ...restProps,
+ options: convertChildrenToData(children),
+ };
+ })
+ .filter(data => data);
+}
diff --git a/src/utils/valueUtil.ts b/src/utils/valueUtil.ts
new file mode 100644
index 000000000..230dc0869
--- /dev/null
+++ b/src/utils/valueUtil.ts
@@ -0,0 +1,282 @@
+import warning from 'rc-util/lib/warning';
+import {
+ OptionsType as SelectOptionsType,
+ OptionData,
+ OptionGroupData,
+ FlattenOptionData,
+} from '../interface';
+import {
+ LabelValueType,
+ FilterFunc,
+ RawValueType,
+ GetLabeledValue,
+ DefaultValueType,
+} from '../interface/generator';
+
+import { toArray } from './commonUtil';
+
+function getKey(data: OptionData | OptionGroupData, index: number) {
+ const { key } = data;
+ let value: RawValueType;
+
+ if ('value' in data) {
+ ({ value } = data);
+ }
+
+ if (key !== null && key !== undefined) {
+ return key;
+ }
+ if (value !== undefined) {
+ return value;
+ }
+ return `rc-index-key-${index}`;
+}
+
+/**
+ * Flat options into flatten list.
+ * We use `optionOnly` here is aim to avoid user use nested option group.
+ * Here is simply set `key` to the index if not provided.
+ */
+export function flattenOptions(
+ options: SelectOptionsType,
+ rootFlattenList?: FlattenOptionData[],
+): FlattenOptionData[] {
+ const flattenList: FlattenOptionData[] = rootFlattenList || [];
+ const isGroupOption = !!rootFlattenList;
+
+ options.forEach(data => {
+ if (isGroupOption || !('options' in data)) {
+ // Option
+ flattenList.push({
+ key: getKey(data, flattenList.length),
+ groupOption: isGroupOption,
+ data,
+ });
+ } else {
+ // Option Group
+ flattenList.push({
+ key: getKey(data, flattenList.length),
+ group: true,
+ data,
+ });
+
+ flattenOptions(data.options, flattenList);
+ }
+ });
+
+ return flattenList;
+}
+
+export function findValueOption(
+ values: RawValueType[],
+ options: FlattenOptionData[],
+): OptionData[] {
+ const optionMap: Map = new Map();
+
+ options.forEach(flattenItem => {
+ if (!flattenItem.group) {
+ const data = flattenItem.data as OptionData;
+ // Check if match
+ optionMap.set(data.value, data);
+ }
+ });
+
+ return values.map(val => optionMap.get(val));
+}
+
+export const getLabeledValue: GetLabeledValue = (
+ value,
+ { options, prevValue, labelInValue, optionLabelProp },
+) => {
+ const item = findValueOption([value], options)[0];
+ const result: LabelValueType = {
+ value,
+ };
+
+ let prevValItem: LabelValueType;
+ const prevValues = toArray(prevValue);
+ if (labelInValue) {
+ prevValItem = prevValues.find((prevItem: LabelValueType) => {
+ if (typeof prevItem === 'object' && 'value' in prevItem) {
+ return prevItem.value === value;
+ }
+ // [Legacy] Support `key` as `value`
+ return prevItem.key === value;
+ }) as LabelValueType;
+ }
+
+ if (prevValItem && typeof prevValItem === 'object' && 'label' in prevValItem) {
+ result.label = prevValItem.label;
+
+ if (
+ item &&
+ typeof prevValItem.label === 'string' &&
+ typeof item[optionLabelProp] === 'string' &&
+ prevValItem.label.trim() !== item[optionLabelProp].trim()
+ ) {
+ warning(false, '`label` of `value` is not same as `label` in Select options.');
+ }
+ } else if (item && optionLabelProp in item) {
+ result.label = item[optionLabelProp];
+ } else {
+ result.label = value;
+ }
+
+ // [Legacy] We need fill `key` as `value` to compatible old code usage
+ result.key = result.value;
+
+ return result;
+};
+
+function toRawString(content: React.ReactNode): string {
+ return toArray(content).join('');
+}
+
+/** Filter single option if match the search text */
+function getFilterFunction(optionFilterProp: string) {
+ return (searchValue: string, option: OptionData | OptionGroupData) => {
+ const lowerSearchText = searchValue.toLowerCase();
+
+ // Group label search
+ if ('options' in option) {
+ return toRawString(option.label)
+ .toLowerCase()
+ .includes(lowerSearchText);
+ }
+
+ // Option value search
+ const rawValue = option[optionFilterProp];
+ const value = toRawString(rawValue).toLowerCase();
+ return value.includes(lowerSearchText) && !option.disabled;
+ };
+}
+
+/** Filter options and return a new options by the search text */
+export function filterOptions(
+ searchValue: string,
+ options: SelectOptionsType,
+ {
+ optionFilterProp,
+ filterOption,
+ }: { optionFilterProp: string; filterOption: boolean | FilterFunc },
+) {
+ const filteredOptions: SelectOptionsType = [];
+ let filterFunc: FilterFunc;
+
+ if (filterOption === false) {
+ return options;
+ }
+ if (typeof filterOption === 'function') {
+ filterFunc = filterOption;
+ } else {
+ filterFunc = getFilterFunction(optionFilterProp);
+ }
+
+ options.forEach(item => {
+ // Group should check child options
+ if ('options' in item) {
+ // Check group first
+ const matchGroup = filterFunc(searchValue, item);
+ if (matchGroup) {
+ filteredOptions.push(item);
+ } else {
+ // Check option
+ const subOptions = item.options.filter(subItem => filterFunc(searchValue, subItem));
+ if (subOptions.length) {
+ filteredOptions.push({
+ ...item,
+ options: subOptions,
+ });
+ }
+ }
+
+ return;
+ }
+
+ if (filterFunc(searchValue, item)) {
+ filteredOptions.push(item);
+ }
+ });
+
+ return filteredOptions;
+}
+
+export function getSeparatedContent(text: string, tokens: string[]): string[] {
+ if (!tokens || !tokens.length) {
+ return null;
+ }
+
+ let match = false;
+
+ function separate(str: string, [token, ...restTokens]: string[]) {
+ if (!token) {
+ return [str];
+ }
+
+ const list = str.split(token);
+ match = match || list.length > 1;
+
+ return list
+ .reduce((prevList, unitStr) => [...prevList, ...separate(unitStr, restTokens)], [])
+ .filter(unit => unit);
+ }
+
+ const list = separate(text, tokens);
+ return match ? list : null;
+}
+
+export function isValueDisabled(value: RawValueType, options: FlattenOptionData[]): boolean {
+ const option = findValueOption([value], options)[0];
+ if (option) {
+ return option.disabled;
+ }
+
+ return false;
+}
+
+/**
+ * `tags` mode should fill un-list item into the option list
+ */
+export function fillOptionsWithMissingValue(
+ options: SelectOptionsType,
+ value: DefaultValueType,
+ optionLabelProp: string,
+ labelInValue: boolean,
+): SelectOptionsType {
+ const values = toArray(value)
+ .slice()
+ .sort();
+ const cloneOptions = [...options];
+
+ // Convert options value to set
+ const optionValues = new Set();
+ options.forEach(opt => {
+ if (opt.options) {
+ opt.options.forEach((subOpt: OptionData) => {
+ optionValues.add(subOpt.value);
+ });
+ } else {
+ optionValues.add((opt as OptionData).value);
+ }
+ });
+
+ // Fill missing value
+ values.forEach(item => {
+ const val: RawValueType = labelInValue
+ ? (item as LabelValueType).value
+ : (item as RawValueType);
+
+ if (!optionValues.has(val)) {
+ cloneOptions.push(
+ labelInValue
+ ? {
+ [optionLabelProp]: (item as LabelValueType).label,
+ value: val,
+ }
+ : { value: val },
+ );
+ }
+ });
+
+ return cloneOptions;
+}
diff --git a/src/utils/warningPropsUtil.ts b/src/utils/warningPropsUtil.ts
new file mode 100644
index 000000000..a3ec383c1
--- /dev/null
+++ b/src/utils/warningPropsUtil.ts
@@ -0,0 +1,155 @@
+import React from 'react';
+import warning, { noteOnce } from 'rc-util/lib/warning';
+import toNodeArray from 'rc-util/lib/Children/toArray';
+import { SelectProps } from '..';
+import { convertChildrenToData } from './legacyUtil';
+import { OptionData, OptionGroupData } from '../interface';
+import { toArray } from './commonUtil';
+import { RawValueType, LabelValueType } from '../interface/generator';
+
+function warningProps(props: SelectProps) {
+ const {
+ mode,
+ options,
+ children,
+ backfill,
+ allowClear,
+ placeholder,
+ getInputElement,
+ showSearch,
+ onSearch,
+ defaultOpen,
+ autoFocus,
+ labelInValue,
+ value,
+ inputValue,
+ optionLabelProp,
+ } = props;
+
+ const mergedShowSearch =
+ showSearch !== undefined ? showSearch : mode === 'tags' || mode === 'combobox';
+ const multiple = mode === 'multiple' || mode === 'tags';
+ const mergedOptions = options || convertChildrenToData(children);
+
+ // `tags` should not set option as disabled
+ warning(
+ mode !== 'tags' ||
+ mergedOptions.every((opt: { disabled?: boolean } & OptionGroupData) => !opt.disabled),
+ 'Please avoid setting option to disabled in tags mode since user can always type text as tag.',
+ );
+
+ // `combobox` & `tags` should option be `string` type
+ if (mode === 'tags' || mode === 'combobox') {
+ const hasNumberValue = mergedOptions.some(item => {
+ if (item.options) {
+ return item.options.some(
+ (opt: OptionData) => typeof ('value' in opt ? opt.value : opt.key) === 'number',
+ );
+ }
+ return typeof ('value' in item ? item.value : item.key) === 'number';
+ });
+
+ warning(
+ !hasNumberValue,
+ '`value` of Option should not use number type when `mode` is `tags` or `combobox`.',
+ );
+ }
+
+ // `combobox` should not use `optionLabelProp`
+ warning(
+ mode !== 'combobox' || !optionLabelProp,
+ '`combobox` mode not support `optionLabelProp`. Please set `value` on Option directly.',
+ );
+
+ // Only `combobox` support `backfill`
+ warning(mode === 'combobox' || !backfill, '`backfill` only works with `combobox` mode.');
+
+ // Only `combobox` support `getInputElement`
+ warning(
+ mode === 'combobox' || !getInputElement,
+ '`getInputElement` only work with `combobox` mode.',
+ );
+
+ // Customize `getInputElement` should not use `allowClear` & `placeholder`
+ noteOnce(
+ mode !== 'combobox' || !getInputElement || !allowClear || !placeholder,
+ 'Customize `getInputElement` should customize clear and placeholder logic instead of configuring `allowClear` and `placeholder`.',
+ );
+
+ // `onSearch` should use in `combobox` or `showSearch`
+ if (onSearch && (!mergedShowSearch && mode !== 'combobox' && mode !== 'tags')) {
+ warning(false, '`onSearch` should work with `showSearch` instead of use alone.');
+ }
+
+ noteOnce(
+ !defaultOpen || autoFocus,
+ '`defaultOpen` makes Select open without focus which means it will not close by click outside. You can set `autoFocus` if needed.',
+ );
+
+ if (value !== undefined && value !== null) {
+ const values = toArray(value);
+ warning(
+ !labelInValue ||
+ values.every(val => typeof val === 'object' && ('key' in val || 'value' in val)),
+ '`value` should in shape of `{ value: string | number, label?: ReactNode }` when you set `labelInValue` to `true`',
+ );
+
+ warning(
+ !multiple || Array.isArray(value),
+ '`value` should be array when `mode` is `multiple` or `tags`',
+ );
+ }
+
+ // Syntactic sugar should use correct children type
+ if (children) {
+ let invalidateChildType = null;
+ toNodeArray(children).some((node: React.ReactNode) => {
+ if (!React.isValidElement(node)) {
+ return false;
+ }
+
+ const { type } = node as { type: { isSelectOption?: boolean; isSelectOptGroup?: boolean } };
+
+ if (type.isSelectOption) {
+ return false;
+ }
+ if (type.isSelectOptGroup) {
+ const allChildrenValid = toNodeArray(node.props.children).every(
+ (subNode: React.ReactElement) => {
+ if (
+ !React.isValidElement(subNode) ||
+ (subNode.type as { isSelectOption?: boolean }).isSelectOption
+ ) {
+ return true;
+ }
+ invalidateChildType = subNode.type;
+ return false;
+ },
+ );
+
+ if (allChildrenValid) {
+ return false;
+ }
+ return true;
+ }
+ invalidateChildType = type;
+ return true;
+ });
+
+ if (invalidateChildType) {
+ warning(
+ false,
+ `\`children\` should be \`Select.Option\` or \`Select.OptGroup\` instead of \`${invalidateChildType.displayName ||
+ invalidateChildType.name ||
+ invalidateChildType}\`.`,
+ );
+ }
+
+ warning(
+ inputValue === undefined,
+ '`inputValue` is deprecated, please use `searchValue` instead.',
+ );
+ }
+}
+
+export default warningProps;
diff --git a/tests/Combobox.test.tsx b/tests/Combobox.test.tsx
new file mode 100644
index 000000000..b9529c327
--- /dev/null
+++ b/tests/Combobox.test.tsx
@@ -0,0 +1,334 @@
+import { mount } from 'enzyme';
+import KeyCode from 'rc-util/lib/KeyCode';
+import React from 'react';
+import { resetWarned } from 'rc-util/lib/warning';
+import Select, { Option, SelectProps } from '../src';
+import focusTest from './shared/focusTest';
+import keyDownTest from './shared/keyDownTest';
+import openControlledTest from './shared/openControlledTest';
+import {
+ expectOpen,
+ toggleOpen,
+ selectItem,
+ injectRunAllTimers,
+} from './utils/common';
+import allowClearTest from './shared/allowClearTest';
+import throwOptionValue from './shared/throwOptionValue';
+
+describe('Select.Combobox', () => {
+ injectRunAllTimers(jest);
+
+ allowClearTest('combobox', '2333');
+ throwOptionValue('combobox');
+ focusTest('combobox');
+ keyDownTest('combobox');
+ openControlledTest('combobox');
+
+ it('renders correctly', () => {
+ const wrapper = mount(
+
+
+
+ ,
+ );
+
+ expect(wrapper.render()).toMatchSnapshot();
+ });
+
+ it('set inputValue based on value', () => {
+ const wrapper = mount(
+
+
+
+ ,
+ );
+
+ expect(wrapper.find('input').props().value).toEqual('1');
+ });
+
+ it('placeholder', () => {
+ const wrapper = mount(
+
+
+
+ ,
+ );
+
+ expect(wrapper.find('input').props().value).toBe('');
+ expect(wrapper.find('.rc-select-selection-placeholder').text()).toEqual('placeholder');
+ wrapper.find('input').simulate('change', { target: { value: '1' } });
+ expect(wrapper.find('.rc-select-selection-placeholder').length).toBeFalsy();
+ expect(wrapper.find('input').props().value).toBe('1');
+ });
+
+ it('fire change event immediately when user inputing', () => {
+ const handleChange = jest.fn();
+ const wrapper = mount(
+
+
+
+ ,
+ );
+
+ wrapper.find('input').simulate('change', { target: { value: '1' } });
+ expect(handleChange).toBeCalledWith('1', undefined);
+
+ wrapper.find('input').simulate('change', { target: { value: '22' } });
+ expect(handleChange).toBeCalledWith(
+ '22',
+ expect.objectContaining({
+ children: '22',
+ value: '22',
+ }),
+ );
+ });
+
+ it('set inputValue when user select a option', () => {
+ const wrapper = mount(
+
+
+
+ ,
+ );
+
+ toggleOpen(wrapper);
+ selectItem(wrapper);
+ expect(wrapper.find('input').props().value).toEqual('1');
+ });
+
+ describe('input value', () => {
+ const createSelect = (props?: Partial) =>
+ mount(
+
+
+
+ ,
+ );
+
+ it('displays correct input value for defaultValue', () => {
+ const wrapper = createSelect({
+ defaultValue: '1',
+ });
+ expect(wrapper.find('input').props().value).toBe('1');
+ });
+
+ it('displays correct input value for value', () => {
+ const wrapper = createSelect({
+ value: '1',
+ });
+ expect(wrapper.find('input').props().value).toBe('1');
+ });
+
+ it('displays correct input value when value changes', () => {
+ const wrapper = createSelect();
+ wrapper.setProps({ value: '1' });
+ expect(wrapper.find('input').props().value).toBe('1');
+ });
+ });
+
+ describe('hidden when filtered options is empty', () => {
+ // https://github.com/ant-design/ant-design/issues/3958
+ it('should popup when input with async data', () => {
+ jest.useFakeTimers();
+ class AsyncCombobox extends React.Component {
+ public state = {
+ data: [],
+ };
+ public handleChange = () => {
+ setTimeout(() => {
+ this.setState({
+ data: [{ key: '1', label: '1' }, { key: '2', label: '2' }],
+ });
+ }, 500);
+ };
+ public render() {
+ const options = this.state.data.map(item => );
+ return (
+
+ {options}
+
+ );
+ }
+ }
+ const wrapper = mount();
+ wrapper.find('input').simulate('focus');
+ wrapper.find('input').simulate('change', { target: { value: '0' } });
+ jest.runAllTimers();
+ wrapper.update();
+ expectOpen(wrapper);
+ jest.useRealTimers();
+ });
+
+ // https://github.com/ant-design/ant-design/issues/6600
+ it('should not re-open after select', () => {
+ jest.useFakeTimers();
+ class AsyncCombobox extends React.Component {
+ public state = {
+ data: [{ key: '1', label: '1' }, { key: '2', label: '2' }],
+ };
+ public onSelect = () => {
+ setTimeout(() => {
+ this.setState({
+ data: [{ key: '3', label: '3' }, { key: '4', label: '4' }],
+ });
+ }, 500);
+ };
+ public render() {
+ const options = this.state.data.map(item => );
+ return (
+
+ {options}
+
+ );
+ }
+ }
+ const wrapper = mount();
+ wrapper.find('input').simulate('focus');
+ wrapper.find('input').simulate('change', { target: { value: '0' } });
+ expectOpen(wrapper);
+
+ selectItem(wrapper);
+ jest.runAllTimers();
+ expectOpen(wrapper, false);
+ jest.useRealTimers();
+ });
+ });
+
+ it('backfill', () => {
+ const handleChange = jest.fn();
+ const handleSelect = jest.fn();
+ const wrapper = mount(
+
+
+
+ ,
+ );
+ const input = wrapper.find('input');
+ input.simulate('keyDown', { which: KeyCode.DOWN });
+ expect(wrapper.find('input').props().value).toEqual('One');
+ expect(handleChange).not.toBeCalled();
+ expect(handleSelect).not.toBeCalled();
+
+ input.simulate('keyDown', { which: KeyCode.ENTER });
+ expect(wrapper.find('input').props().value).toEqual('One');
+ expect(handleChange).toBeCalledWith('One', expect.objectContaining({ value: 'One' }));
+ expect(handleSelect).toBeCalledWith('One', expect.objectContaining({ value: 'One' }));
+ });
+
+ it("should hide clear icon when value is ''", () => {
+ const wrapper = mount(
+
+
+
+ ,
+ );
+
+ expect(wrapper.find('.rc-select-clear-icon').length).toBeFalsy();
+ });
+
+ it("should show clear icon when inputValue is not ''", () => {
+ const wrapper = mount(
+
+
+
+ ,
+ );
+
+ expect(wrapper.find('.rc-select-clear-icon').length).toBeTruthy();
+ });
+
+ it("should hide clear icon when inputValue is ''", () => {
+ const wrapper = mount(
+
+
+
+ ,
+ );
+
+ wrapper.find('input').simulate('change', { target: { value: '1' } });
+ expect(wrapper.find('.rc-select-clear-icon').length).toBeTruthy();
+ wrapper.find('input').simulate('change', { target: { value: '' } });
+ expect(wrapper.find('.rc-select-clear-icon').length).toBeFalsy();
+ });
+
+ it('autocomplete - option update when input change', () => {
+ class App extends React.Component {
+ public state = {
+ options: [],
+ };
+
+ public updateOptions = value => {
+ const options = [value, value + value, value + value + value];
+ this.setState({
+ options,
+ });
+ };
+
+ public render() {
+ return (
+
+ {this.state.options.map(opt => {
+ return ;
+ })}
+
+ );
+ }
+ }
+
+ const wrapper = mount();
+ wrapper.find('input').simulate('change', { target: { value: 'a' } });
+ wrapper.find('input').simulate('change', { target: { value: 'ab' } });
+ expect(wrapper.find('input').props().value).toBe('ab');
+ selectItem(wrapper, 1);
+ expect(wrapper.find('input').prop('value')).toBe('abab');
+ });
+
+ // [Legacy] `optionLabelProp` should not work on `combobox`
+ // https://github.com/ant-design/ant-design/issues/10367
+ // origin test: https://github.com/react-component/select/blob/e5fa4959336f6a423b6e30652b9047510bb6f78f/tests/Select.combobox.spec.tsx#L362
+ it('should autocomplete with correct option value', () => {
+ resetWarned();
+
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+ mount();
+ expect(errorSpy).toHaveBeenCalledWith(
+ 'Warning: `combobox` mode not support `optionLabelProp`. Please set `value` on Option directly.',
+ );
+ errorSpy.mockRestore();
+ });
+
+ // https://github.com/ant-design/ant-design/issues/16572
+ it('close when enter press without active option', () => {
+ jest.useFakeTimers();
+ const onDropdownVisibleChange = jest.fn();
+ const wrapper = mount(
+
+
+
+ ,
+ );
+ wrapper.find('input').simulate('keyDown', {
+ which: KeyCode.ENTER,
+ });
+ jest.runAllTimers();
+ wrapper.update();
+ expect(onDropdownVisibleChange).toBeCalledWith(false);
+ jest.useRealTimers();
+ });
+});
diff --git a/tests/DropdownMenu.spec.tsx b/tests/DropdownMenu.spec.tsx
deleted file mode 100644
index 702dc7ab0..000000000
--- a/tests/DropdownMenu.spec.tsx
+++ /dev/null
@@ -1,95 +0,0 @@
-import { mount, render } from 'enzyme';
-import { Item as MenuItem, ItemGroup as MenuItemGroup } from 'rc-menu';
-import * as React from 'react';
-import DropdownMenu from '../src/DropdownMenu';
-
-describe('DropdownMenu', () => {
- it('renders correctly', () => {
- const menuItems = [
-
-
- ,
-
-
- ,
- ];
-
- const wrapper = render();
-
- expect(wrapper).toMatchSnapshot();
- });
-
- it('save first active item', () => {
- const menuItems = [, ];
-
- const wrapper = mount();
-
- expect(wrapper.instance().firstActiveItem.props.children).toBe('1');
- });
-
- it('set active key to menu', () => {
- const menuItems = [, ];
-
- const wrapper = mount();
-
- wrapper.setProps({ visible: true });
- expect((wrapper.find('Menu').props() as any).activeKey).toBe('1');
-
- wrapper.setProps({ inputValue: 'foo' });
- expect((wrapper.find('Menu').props() as any).activeKey).toBe('');
- });
-
- it('save firstActiveValue', () => {
- const menuItems = [, ];
-
- const wrapper = mount(
- ,
- );
-
- expect(wrapper.instance().firstActiveItem.props.children).toBe('2');
- });
-
- it('set firstActiveValue key to menu', () => {
- const menuItems = [, ];
-
- const wrapper = mount();
-
- wrapper.setProps({ visible: true });
- expect(((wrapper.find('Menu').props() as any) as any).activeKey).toBe('2');
-
- wrapper.setProps({ inputValue: 'foo' });
- expect((wrapper.find('Menu').props() as any).activeKey).toBe('');
- });
-
- it('save value not firstActiveValue', () => {
- const menuItems = [, ];
-
- const wrapper = mount(
- ,
- );
-
- expect(wrapper.instance().firstActiveItem.props.children).toBe('1');
- });
-
- it('save visible and inputValue when update', () => {
- const wrapper = mount();
-
- wrapper.setProps({ visible: true, inputValue: 'foo' });
-
- expect(wrapper.instance().lastVisible).toBe(true);
- expect(wrapper.instance().lastInputValue).toBe('foo');
- });
-
- it('not update when next visible is false', () => {
- const wrapper = mount();
-
- expect(wrapper.instance().shouldComponentUpdate({ visible: true })).toBe(true);
- expect(wrapper.instance().shouldComponentUpdate({ visible: false })).toBe(false);
- });
-
- it('should updated when input value change', () => {
- const wrapper = mount();
-
- expect(wrapper.instance().shouldComponentUpdate({ inputValue: 'test2' })).toBe(true);
- });
-});
diff --git a/tests/Group.test.tsx b/tests/Group.test.tsx
new file mode 100644
index 000000000..2633ebc26
--- /dev/null
+++ b/tests/Group.test.tsx
@@ -0,0 +1,37 @@
+import { mount } from 'enzyme';
+import * as React from 'react';
+import Select, { OptGroup, Option } from '../src';
+
+describe('Select.Group', () => {
+ it('group name support search', () => {
+ const wrapper = mount(
+
+
+ ,
+ );
+
+ wrapper.find('input').simulate('change', { target: { value: 'zombiej' } });
+ expect(wrapper.find('List').props().data).toEqual([
+ expect.objectContaining({ group: true, data: expect.objectContaining({ label: 'zombiej' }) }),
+ expect.objectContaining({ data: expect.objectContaining({ value: '1' }) }),
+ expect.objectContaining({ data: expect.objectContaining({ value: '2' }) }),
+ ]);
+ });
+
+ it('group child option support search', () => {
+ const wrapper = mount(
+
+
+ ,
+ );
+
+ wrapper.find('input').simulate('change', { target: { value: '1' } });
+ expect(wrapper.find('List').props().data).toHaveLength(2);
+ });
+});
diff --git a/tests/Hooks.test.tsx b/tests/Hooks.test.tsx
new file mode 100644
index 000000000..e056420a7
--- /dev/null
+++ b/tests/Hooks.test.tsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import { useLock } from '../src/hooks/useLock';
+import { injectRunAllTimers } from './utils/common';
+
+describe('Hooks', () => {
+ injectRunAllTimers(jest);
+
+ it('useLock', () => {
+ jest.useFakeTimers();
+
+ let outSetLock: (newLock: boolean) => void;
+
+ const Component: React.FC<{}> = () => {
+ const [, setLock] = useLock();
+ outSetLock = setLock;
+ return null;
+ };
+
+ const wrapper = mount();
+
+ outSetLock(true);
+ wrapper.unmount();
+
+ expect(window.clearTimeout).toHaveBeenCalled();
+
+ jest.runAllTimers();
+ jest.useRealTimers();
+ });
+});
diff --git a/tests/Multiple.test.tsx b/tests/Multiple.test.tsx
new file mode 100644
index 000000000..0b3e4c837
--- /dev/null
+++ b/tests/Multiple.test.tsx
@@ -0,0 +1,242 @@
+import { mount } from 'enzyme';
+import KeyCode from 'rc-util/lib/KeyCode';
+import React from 'react';
+import { resetWarned } from 'rc-util/lib/warning';
+import Select, { Option, OptGroup } from '../src';
+import focusTest from './shared/focusTest';
+import blurTest from './shared/blurTest';
+import hoverTest from './shared/hoverTest';
+import renderTest from './shared/renderTest';
+import removeSelectedTest from './shared/removeSelectedTest';
+import dynamicChildrenTest from './shared/dynamicChildrenTest';
+import inputFilterTest from './shared/inputFilterTest';
+import {
+ expectOpen,
+ toggleOpen,
+ selectItem,
+ injectRunAllTimers,
+ findSelection,
+ removeSelection,
+} from './utils/common';
+import allowClearTest from './shared/allowClearTest';
+
+describe('Select.Multiple', () => {
+ injectRunAllTimers(jest);
+
+ allowClearTest('multiple', ['903']);
+ focusTest('multiple');
+ blurTest('multiple');
+ hoverTest('multiple');
+ renderTest('multiple');
+ removeSelectedTest('multiple');
+ dynamicChildrenTest('multiple');
+ inputFilterTest('multiple');
+
+ it('tokenize input', () => {
+ const handleChange = jest.fn();
+ const handleSelect = jest.fn();
+ const wrapper = mount(
+
+
+
+ ,
+ );
+
+ wrapper.find('input').simulate('change', {
+ target: {
+ value: 'One',
+ },
+ });
+ expect(handleChange).not.toHaveBeenCalled();
+
+ handleChange.mockReset();
+ wrapper.find('input').simulate('change', {
+ target: {
+ value: 'One,Two,Three',
+ },
+ });
+ expect(handleChange).toHaveBeenCalledWith(['1', '2'], expect.anything());
+
+ handleChange.mockReset();
+ wrapper.find('input').simulate('change', {
+ target: {
+ value: 'One,Two',
+ },
+ });
+ expect(handleChange).toHaveBeenCalledWith(['1', '2'], expect.anything());
+
+ expect(wrapper.find('input').props().value).toBe('');
+ wrapper.update();
+ expectOpen(wrapper, false);
+ });
+
+ it('focus', () => {
+ jest.useFakeTimers();
+ const handleFocus = jest.fn();
+ const wrapper = mount(
+
+
+
+ ,
+ );
+ wrapper.find('input').simulate('focus');
+ jest.runAllTimers();
+ expect(handleFocus).toHaveBeenCalled();
+ jest.useRealTimers();
+ });
+
+ it('OptGroup without key', () => {
+ expect(() => {
+ mount(
+
+
+
+ ,
+ );
+ }).not.toThrow();
+ });
+
+ it('allow number value', () => {
+ const handleChange = jest.fn();
+
+ const wrapper = mount(
+
+
+
+ ,
+ );
+
+ expect(findSelection(wrapper).text()).toEqual('1');
+
+ toggleOpen(wrapper);
+ selectItem(wrapper, 1);
+
+ expect(handleChange).toHaveBeenCalledWith(
+ [1, 2],
+ [expect.objectContaining({ value: 1 }), expect.objectContaining({ value: 2, testprop: 2 })],
+ );
+ });
+
+ it('do not open when close button click', () => {
+ const wrapper = mount(
+
+
+
+ ,
+ );
+
+ toggleOpen(wrapper);
+ selectItem(wrapper, 0);
+ selectItem(wrapper, 1);
+
+ toggleOpen(wrapper);
+ removeSelection(wrapper);
+ expectOpen(wrapper, false);
+ expect(wrapper.find('Selector').props().values).toEqual([
+ expect.objectContaining({ value: 2 }),
+ ]);
+ });
+
+ it('select when item enter', () => {
+ const wrapper = mount(
+
+
+
+ ,
+ );
+
+ toggleOpen(wrapper);
+ wrapper
+ .find('div.rc-select-item-option')
+ .at(1)
+ .simulate('mouseMove');
+
+ wrapper.find('input').simulate('keyDown', { which: KeyCode.ENTER });
+ expectOpen(wrapper);
+ expect(wrapper.find('Selector').props().values).toEqual([
+ expect.objectContaining({ value: 2 }),
+ ]);
+ });
+
+ it('enter twice to cancel the selection', () => {
+ const wrapper = mount(
+
+
+
+ ,
+ );
+
+ toggleOpen(wrapper);
+ wrapper
+ .find('div.rc-select-item-option')
+ .first()
+ .simulate('mousemove');
+ wrapper.find('input').simulate('keyDown', { which: KeyCode.ENTER });
+
+ wrapper
+ .find('div.rc-select-item-option')
+ .first()
+ .simulate('mousemove');
+ wrapper.find('input').simulate('keyDown', { which: KeyCode.ENTER });
+
+ expect(wrapper.find('Selector').props().values).toEqual([]);
+ });
+
+ it('do not crash when children has empty', () => {
+ const wrapper = mount(
+
+ {null}
+
+
+ ,
+ );
+
+ toggleOpen(wrapper);
+ selectItem(wrapper);
+
+ // Do not crash
+ });
+
+ it('do not crash when value has empty string', () => {
+ const wrapper = mount(
+
+
+
+ ,
+ );
+
+ expect(findSelection(wrapper).text()).toEqual('');
+ });
+
+ it('show arrow on multiple mode when explicitly set', () => {
+ const wrapper = mount(
+
+
+
+ ,
+ );
+
+ expect(wrapper.find('.rc-select-arrow-icon').length).toBeFalsy();
+
+ wrapper.setProps({
+ showArrow: true,
+ });
+ expect(wrapper.find('.rc-select-arrow-icon').length).toBeTruthy();
+ });
+});
diff --git a/tests/OptionList.test.tsx b/tests/OptionList.test.tsx
new file mode 100644
index 000000000..d2f8a84e2
--- /dev/null
+++ b/tests/OptionList.test.tsx
@@ -0,0 +1,140 @@
+import { mount } from 'enzyme';
+import KeyCode from 'rc-util/lib/KeyCode';
+import { act } from 'react-dom/test-utils';
+import React from 'react';
+import OptionList, { OptionListProps, RefOptionListProps } from '../src/OptionList';
+import { injectRunAllTimers } from './utils/common';
+import { OptionsType } from '../src/interface';
+import { flattenOptions } from '../src/utils/valueUtil';
+
+describe('OptionList', () => {
+ injectRunAllTimers(jest);
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ function generateList({
+ options,
+ ...props
+ }: { options: OptionsType } & Partial> & { ref?: any }) {
+ const flatten = flattenOptions(options);
+
+ return (
+
+ {}}
+ values={new Set()}
+ options={options}
+ flattenOptions={flatten}
+ {...(props as any)}
+ />
+
+ );
+ }
+
+ it('renders correctly', () => {
+ const wrapper = mount(
+ generateList({
+ options: [
+ {
+ key: 'group1',
+ options: [{ value: '1' }],
+ },
+ {
+ key: 'group2',
+ options: [{ value: '2' }],
+ },
+ ],
+ values: new Set(['1']),
+ }),
+ );
+ expect(wrapper.render()).toMatchSnapshot();
+ });
+
+ it('save first active item', () => {
+ const onActiveValue = jest.fn();
+
+ mount(
+ generateList({
+ options: [{ value: '1' }, { value: '2' }],
+ values: new Set('1'),
+ onActiveValue,
+ }),
+ );
+
+ expect(onActiveValue).toHaveBeenCalledWith('1', expect.anything());
+ });
+
+ it('key operation', () => {
+ const onActiveValue = jest.fn();
+ const listRef = React.createRef();
+ mount(
+ generateList({
+ options: [{ value: '1' }, { value: '2' }],
+ onActiveValue,
+ ref: listRef,
+ }),
+ );
+
+ onActiveValue.mockReset();
+ act(() => {
+ listRef.current.onKeyDown({ which: KeyCode.DOWN } as any);
+ });
+ expect(onActiveValue).toHaveBeenCalledWith('2', expect.anything());
+
+ onActiveValue.mockReset();
+ act(() => {
+ listRef.current.onKeyDown({ which: KeyCode.UP } as any);
+ });
+ expect(onActiveValue).toHaveBeenCalledWith('1', expect.anything());
+ });
+
+ it('hover to active', () => {
+ const onActiveValue = jest.fn();
+ const wrapper = mount(
+ generateList({
+ options: [{ value: '1' }, { value: '2' }],
+ onActiveValue,
+ }),
+ );
+
+ onActiveValue.mockReset();
+ wrapper
+ .find('.rc-select-item-option')
+ .last()
+ .simulate('mouseMove');
+ expect(onActiveValue).toHaveBeenCalledWith('2', expect.anything());
+
+ // Same item not repeat trigger
+ onActiveValue.mockReset();
+ wrapper
+ .find('.rc-select-item-option')
+ .last()
+ .simulate('mouseMove');
+ expect(onActiveValue).not.toHaveBeenCalled();
+ });
+
+ it('Should prevent default with list mouseDown to avoid losing focus', () => {
+ const wrapper = mount(
+ generateList({
+ options: [{ value: '1' }, { value: '2' }],
+ }),
+ );
+
+ const preventDefault = jest.fn();
+ wrapper
+ .find('.rc-select-item-option')
+ .last()
+ .simulate('mouseDown', {
+ preventDefault,
+ });
+
+ expect(preventDefault).toHaveBeenCalled();
+ });
+});
diff --git a/tests/Select.combobox.spec.tsx b/tests/Select.combobox.spec.tsx
deleted file mode 100644
index f2bd83363..000000000
--- a/tests/Select.combobox.spec.tsx
+++ /dev/null
@@ -1,401 +0,0 @@
-import { mount, render } from 'enzyme';
-import KeyCode from 'rc-util/lib/KeyCode';
-import * as React from 'react';
-import Select, { Option } from '../src';
-import allowClearTest from './shared/allowClearTest';
-import focusTest from './shared/focusTest';
-import keyDownTest from './shared/keyDownTest';
-import openControlledTest from './shared/openControlledTest';
-import throwOptionValue from './shared/throwOptionValue';
-
-describe('Select.combobox', () => {
- allowClearTest('combobox');
- throwOptionValue('combobox');
- focusTest('combobox', {});
- keyDownTest('combobox');
- openControlledTest('combobox');
-
- it('renders correctly', () => {
- const wrapper = render(
-
-
-
- ,
- );
-
- expect(wrapper).toMatchSnapshot();
- });
-
- it('set inputValue based on value', () => {
- const wrapper = mount(
-
-
-
- ,
- );
-
- expect(wrapper.state().inputValue).toBe('1');
- });
-
- it('placeholder', () => {
- const wrapper = mount(
-
-
-
- ,
- );
-
- expect(wrapper.state().inputValue).toBe('');
- expect(wrapper.find('.rc-select-selection__placeholder').text()).toBe('placeholder');
- expect(wrapper.find('.rc-select-selection__placeholder').prop('style')).toHaveProperty(
- 'display',
- 'block',
- );
- wrapper.find('input').simulate('change', { target: { value: '1' } });
- expect(
- wrapper
- .update()
- .find('.rc-select-selection__placeholder')
- .prop('style'),
- ).toHaveProperty('display', 'none');
- expect(wrapper.state().inputValue).toBe('1');
- });
-
- it('fire change event immediately when user inputing', () => {
- const handleChange = jest.fn();
- const wrapper = mount(
-
-
-
- ,
- );
-
- wrapper.find('input').simulate('change', { target: { value: '1' } });
-
- expect(handleChange).toBeCalledWith(
- '1',
- ,
- );
- });
-
- it('set inputValue when user select a option', () => {
- const wrapper = mount(
-
-
-
- ,
- );
-
- wrapper.find('.rc-select').simulate('click');
- wrapper
- .find('MenuItem')
- .first()
- .simulate('click');
- expect(wrapper.state().inputValue).toBe('1');
- });
-
- describe('input value', () => {
- const createSelect = props =>
- mount(
-
-
-
- ,
- );
-
- describe('labelInValue is false', () => {
- it('displays correct input value for defaultValue', () => {
- const wrapper = createSelect({
- defaultValue: '1',
- });
- expect(wrapper.find('input').props().value).toBe('One');
- });
-
- it('displays correct input value for value', () => {
- const wrapper = createSelect({
- value: '1',
- });
- expect(wrapper.find('input').props().value).toBe('One');
- });
- });
-
- describe('labelInValue is true', () => {
- it('displays correct input value for defaultValue', () => {
- const wrapper = createSelect({
- labelInValue: true,
- defaultValue: { key: '1', label: 'One' },
- });
- expect(wrapper.find('input').props().value).toBe('One');
- });
-
- it('displays correct input value for value', () => {
- const wrapper = createSelect({
- labelInValue: true,
- value: { key: '1', label: 'One' },
- });
- expect(wrapper.find('input').props().value).toBe('One');
- });
-
- it('displays correct input value when value changes', () => {
- const wrapper = createSelect({
- labelInValue: true,
- value: { key: '' },
- });
- wrapper.setProps({ value: { key: '1', label: 'One' } });
- expect(wrapper.find('input').props().value).toBe('One');
- });
- });
-
- describe('hidden when filtered options is empty', () => {
- // https://github.com/ant-design/ant-design/issues/3958
- it('should popup when input with async data', () => {
- jest.useFakeTimers();
- class AsyncCombobox extends React.Component {
- public state = {
- data: [],
- };
- public handleChange = () => {
- setTimeout(() => {
- this.setState({
- data: [{ key: '1', label: '1' }, { key: '2', label: '2' }],
- });
- }, 500);
- };
- public render() {
- const options = this.state.data.map(item => (
-
- ));
- return (
-
- {options}
-
- );
- }
- }
- const wrapper = mount();
- wrapper.find('input').simulate('focus');
- wrapper.find('input').simulate('change', { target: { value: '0' } });
- jest.runAllTimers();
- wrapper.update();
- expect(
- wrapper
- .find('.rc-select-dropdown')
- .hostNodes()
- .hasClass('rc-select-dropdown-hidden'),
- ).toBe(false);
- });
-
- // https://github.com/ant-design/ant-design/issues/6600
- it('should not repop menu after select', () => {
- jest.useFakeTimers();
- // tslint:disable-next-line:max-classes-per-file
- class AsyncCombobox extends React.Component {
- public state = {
- data: [{ key: '1', label: '1' }, { key: '2', label: '2' }],
- };
- public onSelect = () => {
- setTimeout(() => {
- this.setState({
- data: [{ key: '3', label: '3' }, { key: '4', label: '4' }],
- });
- }, 500);
- };
- public render() {
- const options = this.state.data.map(item => (
-
- ));
- return (
-
- {options}
-
- );
- }
- }
- const wrapper = mount();
- wrapper.find('input').simulate('focus');
- wrapper.find('input').simulate('change', { target: { value: '0' } });
- expect(
- wrapper
- .find('.rc-select-dropdown')
- .hostNodes()
- .hasClass('rc-select-dropdown-hidden'),
- ).toBe(false);
- wrapper
- .find('MenuItem')
- .first()
- .simulate('click');
- jest.runAllTimers();
- expect(
- wrapper
- .find('.rc-select-dropdown')
- .hostNodes()
- .hasClass('rc-select-dropdown-hidden'),
- ).toBe(true);
- });
- });
- });
-
- it('backfill', () => {
- const handleChange = jest.fn();
- const handleSelect = jest.fn();
- const wrapper = mount(
-
-
-
- ,
- );
-
- const input = wrapper.find('input');
-
- input.simulate('keyDown', { keyCode: KeyCode.DOWN });
-
- expect(wrapper.state().value).toEqual(['Two']);
- expect(wrapper.state().backfillValue).toEqual('Two');
- expect(wrapper.state().inputValue).toBe('Two');
- expect(
- wrapper
- .find('MenuItem')
- .at(1)
- .text(),
- ).toBe('Two');
- expect(handleChange).not.toBeCalled();
- expect(handleSelect).not.toBeCalled();
-
- input.simulate('keyDown', { keyCode: KeyCode.ENTER });
-
- expect(wrapper.state().value).toEqual(['Two']);
- expect(handleChange).toBeCalledWith('Two', );
- expect(handleSelect).toBeCalledWith('Two', );
- });
-
- it("should hide clear icon when value is ''", () => {
- const wrapper = mount(
-
-
-
- ,
- );
-
- expect(wrapper.find('.rc-select-selection__clear').length).toBe(0);
- });
-
- it("should show clear icon when inputValue is not ''", () => {
- const wrapper = mount(
-
-
-
- ,
- );
-
- expect(wrapper.find('.rc-select-selection__clear').length).toBe(1);
- });
-
- it("should hide clear icon when inputValue is ''", () => {
- const wrapper = mount(
-
-
-
- ,
- );
-
- wrapper.find('input').simulate('change', { target: { value: '1' } });
- expect(wrapper.find('.rc-select-selection__clear').length).toBe(1);
- wrapper.find('input').simulate('change', { target: { value: '' } });
- expect(wrapper.find('.rc-select-selection__clear').length).toBe(0);
- });
-
- it('autocomplete - option update when input change', () => {
- // tslint:disable-next-line:max-classes-per-file
- class App extends React.Component {
- public state = {
- options: [],
- };
-
- public updateOptions = value => {
- const options = [value, value + value, value + value + value];
- this.setState({
- options,
- });
- };
-
- public render() {
- return (
-
- {this.state.options.map(opt => {
- return ;
- })}
-
- );
- }
- }
-
- const wrapper = mount();
- wrapper.find('input').simulate('change', { target: { value: 'a' } });
- wrapper.find('input').simulate('change', { target: { value: 'ab' } });
- expect(wrapper.find('input').prop('value')).toBe('ab');
- wrapper
- .find('MenuItem')
- .at(1)
- .simulate('click');
- expect(wrapper.find('input').prop('value')).toBe('abab');
- });
-
- // https://github.com/ant-design/ant-design/issues/10367
- it('should autocomplete with correct option value', () => {
- const wrapper = mount(
-
- {[1, 11, 111, 1111].map(opt => {
- return ;
- })}
- ,
- );
- wrapper.find('input').simulate('change', { target: { value: '1' } });
- wrapper
- .find('MenuItem')
- .at(0)
- .simulate('click');
- expect(wrapper.find('input').prop('value')).toBe('xxx-1');
- });
-
- // https://github.com/ant-design/ant-design/issues/16572
- it('close when enter press without active option', () => {
- jest.useFakeTimers();
-
- const onDropdownVisibleChange = jest.fn();
-
- const wrapper = mount(
-
-
-
- ,
- );
-
- wrapper.find('input').simulate('keyDown', {
- keyCode: KeyCode.ENTER,
- });
-
- jest.runAllTimers();
- wrapper.update();
- expect(onDropdownVisibleChange).toBeCalledWith(false);
-
- jest.useRealTimers();
- });
-});
diff --git a/tests/Select.multiple.spec.tsx b/tests/Select.multiple.spec.tsx
deleted file mode 100644
index 6533f4962..000000000
--- a/tests/Select.multiple.spec.tsx
+++ /dev/null
@@ -1,271 +0,0 @@
-import { mount } from 'enzyme';
-import * as React from 'react';
-import Select, { OptGroup, Option } from '../src';
-import allowClearTest from './shared/allowClearTest';
-import blurTest from './shared/blurTest';
-import dynamicChildrenTest from './shared/dynamicChildrenTest';
-import focusTest from './shared/focusTest';
-import hoverTest from './shared/hoverTest';
-import inputFilterTest from './shared/inputFilterTest';
-import removeSelectedTest from './shared/removeSelectedTest';
-import renderTest from './shared/renderTest';
-
-describe('Select.multiple', () => {
- allowClearTest('multiple');
- focusTest('multiple', {});
- blurTest('multiple');
- hoverTest('multiple');
- renderTest('multiple');
- removeSelectedTest('multiple');
- dynamicChildrenTest('multiple', {});
- inputFilterTest('multiple');
-
- it('tokenize input', () => {
- const handleChange = jest.fn();
- const handleSelect = jest.fn();
- const wrapper = mount(
-
-
-
- ,
- );
-
- const input = wrapper.find('input');
- (input.instance() as any).focus = jest.fn();
-
- input.simulate('change', {
- target: {
- value: 'One',
- },
- });
-
- input.simulate('change', {
- target: {
- value: 'One,Two,Three',
- },
- });
-
- input.simulate('change', {
- target: {
- value: 'One,Two',
- },
- });
-
- expect(handleChange).toBeCalledWith(['1', '2'], expect.anything());
- expect(handleChange).toHaveBeenCalledTimes(1);
- expect(handleSelect).toHaveBeenCalledTimes(2);
- expect(wrapper.state().value).toEqual(['1', '2']);
- expect(
- wrapper
- .find('.rc-select-selection__choice__content')
- .at(0)
- .text(),
- ).toEqual('One');
- expect(
- wrapper
- .find('.rc-select-selection__choice__content')
- .at(1)
- .text(),
- ).toEqual('Two');
- expect(wrapper.state().inputValue).toBe('');
- expect(wrapper.state().open).toBe(false);
- expect((input.instance() as any).focus).toBeCalled();
- });
-
- it('focus', () => {
- const handleFocus = jest.fn();
- const wrapper = mount(
-
-
-
- ,
- );
- jest.useFakeTimers();
- wrapper.find('input').simulate('focus');
- jest.runAllTimers();
- expect(handleFocus).toBeCalled();
- });
-
- it('OptGroup without key', () => {
- expect(() => {
- mount(
-
-
-
- ,
- );
- }).not.toThrow();
- });
-
- it('allow number value', () => {
- const handleChange = jest.fn();
-
- const wrapper = mount(
-
-
-
- ,
- );
-
- expect(wrapper.find('.rc-select-selection__choice__content').text()).toBe('1');
-
- wrapper.find('.rc-select').simulate('click');
- wrapper
- .find('MenuItem')
- .at(1)
- .simulate('click');
-
- const args = handleChange.mock.calls[0];
- expect(args[0]).toEqual([1, 2]);
- expect(args[1].length).toBe(2);
-
- // magic code
- // expect(handleChange).toBeCalledWith(
- // [1, 2],
- // [
- // ,
- // ,
- // ],
- // );
-
- expect(
- wrapper
- .find('.rc-select-selection__choice__content')
- .at(1)
- .text(),
- ).toBe('2');
- });
-
- it('do not open when close button click', () => {
- const wrapper = mount(
-
-
-
- ,
- );
- wrapper.find('.rc-select-selection').simulate('click');
- wrapper
- .find('.rc-select-dropdown-menu-item')
- .at(0)
- .simulate('click');
- wrapper
- .find('.rc-select-dropdown-menu-item')
- .at(1)
- .simulate('click');
- wrapper.setState({ open: false });
- wrapper
- .find('.rc-select-selection__choice__remove')
- .at(0)
- .simulate('click');
- expect(wrapper.state('open')).toBe(false);
- expect(wrapper.state('value')).toEqual([2]);
- });
-
- it('select when item enter', () => {
- const wrapper = mount(
-
-
-
- ,
- );
- wrapper.find('.rc-select-selection').simulate('click');
- const meunItem = wrapper.find('.rc-select-dropdown-menu-item').at(1);
- // add active to meunItem
- meunItem
- .simulate('mouseenter')
- .simulate('mouseover')
- .simulate('keyDown', { keyCode: 13 });
- expect(wrapper.state('open')).toBe(true);
- expect(wrapper.state('value')).toEqual([2]);
- wrapper.unmount();
- });
-
- it('enter twice to cancel the selection', () => {
- const wrapper = mount(
-
-
-
- ,
- );
- wrapper.find('.rc-select-selection').simulate('click');
- const meunItem = wrapper.find('.rc-select-dropdown-menu-item').at(1);
- // add active to meunItem
- meunItem
- .simulate('mouseenter')
- .simulate('mouseover')
- .simulate('keyDown', { keyCode: 13 });
- meunItem
- .simulate('mouseenter')
- .simulate('mouseover')
- .simulate('keyDown', { keyCode: 13 });
- expect(wrapper.state('open')).toBe(true);
- expect(wrapper.state('value')).toEqual([]);
- });
-
- it('do not crash when children has empty', () => {
- const wrapper = mount(
-
- {null}
-
-
- ,
- );
-
- wrapper.find('.rc-select-selection').simulate('click');
- wrapper
- .find('.rc-select-dropdown-menu-item')
- .at(0)
- .simulate('click');
-
- // Do not crash
- });
-
- it('do not crash when value has empty string', () => {
- const wrapper = mount(
-
-
-
- ,
- );
-
- expect(wrapper.find('.rc-select-selection__choice__content').length).toBe(1);
- });
-
- it('show arrow on multiple mode when explicitly set', () => {
- // multiple=true arrow don't have
- const wrapper = mount(
-
-
-
- ,
- );
-
- expect(wrapper.find('.rc-select-arrow-icon').length).toBe(0);
-
- // multiple=true showArrow=true arrow do have
- wrapper.setProps({
- showArrow: true,
- });
- expect(wrapper.find('.rc-select-arrow-icon').length).toBe(1);
- });
-});
diff --git a/tests/Select.optGroup.spec.tsx b/tests/Select.optGroup.spec.tsx
deleted file mode 100644
index 8b78338f7..000000000
--- a/tests/Select.optGroup.spec.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import { mount } from 'enzyme';
-import * as React from 'react';
-import Select, { OptGroup, Option } from '../src';
-
-describe('Select.optionGroup', () => {
- it('group name support search', () => {
- const wrapper = mount(
-
-
- ,
- );
-
- wrapper.find('input').simulate('change', { target: { value: 'zombiej' } });
- expect(wrapper.find('MenuItemGroup').length).toBe(1);
- expect(wrapper.find('MenuItem').length).toBe(2);
- });
-});
diff --git a/tests/Select.tags.spec.tsx b/tests/Select.tags.spec.tsx
deleted file mode 100644
index 4089ec37b..000000000
--- a/tests/Select.tags.spec.tsx
+++ /dev/null
@@ -1,241 +0,0 @@
-import { mount, render } from 'enzyme';
-import KeyCode from 'rc-util/lib/KeyCode';
-import * as React from 'react';
-import Select, { OptGroup, Option } from '../src';
-import allowClearTest from './shared/allowClearTest';
-import blurTest from './shared/blurTest';
-import dynamicChildrenTest from './shared/dynamicChildrenTest';
-import focusTest from './shared/focusTest';
-import hoverTest from './shared/hoverTest';
-import inputFilterTest from './shared/inputFilterTest';
-import openControlledTest from './shared/openControlledTest';
-import removeSelectedTest from './shared/removeSelectedTest';
-import renderTest from './shared/renderTest';
-import throwOptionValue from './shared/throwOptionValue';
-
-describe('Select.tags', () => {
- allowClearTest('tags');
- focusTest('tags', {});
- blurTest('tags');
- hoverTest('tags');
- renderTest('tags');
- removeSelectedTest('tags');
- throwOptionValue('tags');
- dynamicChildrenTest('tags', {});
- inputFilterTest('tags');
- openControlledTest('tags');
-
- it('allow user input tags', () => {
- const wrapper = mount();
-
- wrapper
- .find('input')
- .simulate('change', { target: { value: 'foo' } })
- .simulate('keyDown', { keyCode: KeyCode.ENTER });
-
- expect(wrapper.state().value).toEqual(['foo']);
- expect(
- wrapper
- .update()
- .find('.rc-select-selection__choice__content')
- .text(),
- ).toBe('foo');
- });
-
- it('should call onChange on blur', () => {
- const wrapper = mount();
-
- jest.useFakeTimers();
- wrapper
- .find('input')
- .simulate('change', { target: { value: 'foo' } })
- .simulate('blur');
-
- jest.runAllTimers();
- expect(wrapper.state().value).toEqual(['foo']);
- expect(
- wrapper
- .update()
- .find('.rc-select-selection__choice__content')
- .text(),
- ).toBe('foo');
- });
-
- it('tokenize input', () => {
- const handleChange = jest.fn();
- const handleSelect = jest.fn();
- const option2 = ;
- const wrapper = mount(
-
-
- {option2}
- ,
- );
- // @HACK
- const input = wrapper.find('input') as any;
- input.instance().focus = jest.fn();
-
- input.simulate('change', { target: { value: '2,3,4' } });
-
- expect(handleChange).toBeCalledWith(['2', '3', '4'], expect.anything());
- expect(handleSelect).toHaveBeenCalledTimes(3);
- expect(handleSelect).toHaveBeenLastCalledWith(
- '4',
- ,
- );
- expect(wrapper.state().value).toEqual(['2', '3', '4']);
- expect(
- wrapper
- .find('.rc-select-selection__choice__content')
- .at(0)
- .text(),
- ).toBe('2');
- expect(
- wrapper
- .find('.rc-select-selection__choice__content')
- .at(1)
- .text(),
- ).toBe('3');
- expect(
- wrapper
- .find('.rc-select-selection__choice__content')
- .at(2)
- .text(),
- ).toBe('4');
- expect(wrapper.state().inputValue).toBe('');
- expect(wrapper.state().open).toBe(false);
- expect(input.instance().focus).toBeCalled();
- });
-
- it('renders unlisted item in value', () => {
- const wrapper = render(
-
-
-
- ,
- );
-
- expect(wrapper).toMatchSnapshot();
- });
-
- it('dropdown keeps order', () => {
- const wrapper = mount();
-
- wrapper.find('input').simulate('change', { target: { value: 'aaa' } });
- wrapper.update();
- expect(wrapper.state().open).toBe(true);
- expect(
- wrapper
- .find('.rc-select-dropdown-menu')
- .at(0)
- .render(),
- ).toMatchSnapshot();
- });
-
- it('renders search value when not found', () => {
- const wrapper = render(
-
-
- ,
- );
-
- expect(wrapper).toMatchSnapshot();
- });
-
- it('use filterOption', () => {
- const filterOption = (inputValue, option) =>
- option.props.value.toLowerCase().indexOf(inputValue.toLowerCase()) !== -1;
-
- const wrapper = render(
-
-
- ,
- );
-
- expect(wrapper).toMatchSnapshot();
- });
-
- it('filterOption is false', () => {
- const wrapper = mount(
-
-
-
- ,
- );
- // @HACK
- const input = wrapper.find('input') as any;
- input.instance().focus = jest.fn();
- input
- .simulate('change', { target: { value: 'a' } })
- .simulate('keyDown', { keyCode: KeyCode.ENTER });
-
- expect(wrapper.state().value).toEqual(['a']);
- expect(wrapper.find('.rc-select-selection__choice__content').text()).toBe('a');
- });
-
- describe('OptGroup', () => {
- const createSelect = props => (
-
-
-
-
-
-
- );
-
- it('renders correctly', () => {
- const wrapper = mount(createSelect({ value: ['jack', 'foo'] }));
- wrapper.find('.rc-select').simulate('click');
- expect(wrapper.render()).toMatchSnapshot();
- });
-
- it('renders inputValue correctly', () => {
- const wrapper = mount(createSelect({}));
- wrapper.find('.rc-select').simulate('click');
-
- wrapper.find('input').simulate('change', { target: { value: 'foo' } });
- expect(wrapper.render()).toMatchSnapshot();
-
- wrapper.find('input').simulate('keyDown', { keyCode: KeyCode.ENTER });
- expect(wrapper.render()).toMatchSnapshot();
- });
-
- it('should work fine when filterOption function exists', () => {
- const children = [];
- for (let i = 10; i < 36; i++) {
- children.push(
- ,
- );
- }
- const wrapper = mount(
- key.indexOf(input) >= 0}
- >
- {children}
- ,
- );
- wrapper.find('.rc-select').simulate('click');
-
- wrapper.find('input').simulate('change', { target: { value: 'f' } });
- expect(wrapper.find('.rc-select-dropdown-menu-item').length).toBe(2);
-
- wrapper.find('input').simulate('keyDown', { keyCode: KeyCode.ENTER });
- expect(wrapper.find('.rc-select-selection__choice__content').text()).toEqual('f');
- });
- });
-});
diff --git a/tests/Select.spec.tsx b/tests/Select.test.tsx
similarity index 50%
rename from tests/Select.spec.tsx
rename to tests/Select.test.tsx
index a9797bf49..77c4310bb 100644
--- a/tests/Select.spec.tsx
+++ b/tests/Select.test.tsx
@@ -1,14 +1,24 @@
import { mount, render } from 'enzyme';
import KeyCode from 'rc-util/lib/KeyCode';
-import * as React from 'react';
-import Select, { OptGroup, Option } from '../src';
-import blurTest from './shared/blurTest';
+import React from 'react';
+import { resetWarned } from 'rc-util/lib/warning';
+import Select, { OptGroup, Option, SelectProps } from '../src';
import focusTest from './shared/focusTest';
-import inputFilterTest from './shared/inputFilterTest';
+import blurTest from './shared/blurTest';
import keyDownTest from './shared/keyDownTest';
+import inputFilterTest from './shared/inputFilterTest';
import openControlledTest from './shared/openControlledTest';
+import {
+ expectOpen,
+ toggleOpen,
+ selectItem,
+ findSelection,
+ injectRunAllTimers,
+} from './utils/common';
+
+describe('Select.Basic', () => {
+ injectRunAllTimers(jest);
-describe('Select', () => {
focusTest('single', {});
blurTest('single');
keyDownTest('single');
@@ -16,57 +26,59 @@ describe('Select', () => {
openControlledTest('single');
describe('render', () => {
- const select = (
-
-
-
-
- );
+ function genSelect(props?: Partial) {
+ return (
+
+
+
+
+ );
+ }
it('renders correctly', () => {
- const wrapper = render(select);
+ const wrapper = render(genSelect());
expect(wrapper).toMatchSnapshot();
});
it('renders dropdown correctly', () => {
- const wrapper = render(React.cloneElement(select, { open: true }));
+ const wrapper = render(genSelect({ open: true }));
expect(wrapper).toMatchSnapshot();
});
- it('renders disabeld select correctly', () => {
- const wrapper = render(React.cloneElement(select, { disabled: true }));
+ it('renders disabled select correctly', () => {
+ const wrapper = render(genSelect({ disabled: true }));
expect(wrapper).toMatchSnapshot();
});
it('renders data-attributes correctly', () => {
const wrapper = render(
- React.cloneElement(select, {
+ genSelect({
'data-test': 'test-id',
'data-id': '12345',
- }),
+ } as any),
);
expect(wrapper).toMatchSnapshot();
});
it('renders aria-attributes correctly', () => {
const wrapper = render(
- React.cloneElement(select, {
+ genSelect({
'aria-labelledby': 'test-id',
'aria-label': 'some-label',
}),
@@ -74,18 +86,19 @@ describe('Select', () => {
expect(wrapper).toMatchSnapshot();
});
+ // [Legacy] Should not use `role` since it's meaningless
it('renders role prop correctly', () => {
const wrapper = render(
- React.cloneElement(select, {
+ genSelect({
role: 'button',
- }),
+ } as any),
);
expect(wrapper).toMatchSnapshot();
});
});
it('convert value to array', () => {
- const wrapper = mount(
+ const wrapper = mount(
,
);
- expect(wrapper.state().value).toEqual(['1']);
- expect(wrapper.find('.rc-select-selection-selected-value').text()).toEqual('1-label');
- expect(wrapper.find('.rc-select-selection-selected-value').prop('title')).toEqual('一');
+
+ expect(wrapper.find('Selector').props().values).toEqual([
+ expect.objectContaining({ label: '1-label', value: '1' }),
+ ]);
+ expect(findSelection(wrapper).text()).toEqual('1-label');
});
it('convert defaultValue to array', () => {
- const wrapper = mount(
+ const wrapper = mount(
,
);
- expect(wrapper.state().value).toEqual(['1']);
- expect(wrapper.find('.rc-select-selection-selected-value').text()).toEqual('1');
- expect(wrapper.find('.rc-select-selection-selected-value').prop('title')).toEqual('一');
+ expect(wrapper.find('Selector').props().values).toEqual([
+ expect.objectContaining({ value: '1' }),
+ ]);
+ expect(findSelection(wrapper).text()).toEqual('1');
});
it('not add open className when result is empty and no notFoundContent given', () => {
- const wrapper = mount();
- const select = wrapper.find('.rc-select');
- select.simulate('click');
- expect(select.props().className).not.toContain('-open');
+ const wrapper = mount();
+ toggleOpen(wrapper);
+ expectOpen(wrapper, false);
});
it('should show empty class', () => {
- const wrapper = mount();
+ const wrapper1 = mount(
+
+ Bamboo
+ ,
+ );
expect(
- wrapper
+ wrapper1
.find('.rc-select-dropdown')
.first()
- .hasClass('rc-select-dropdown--single'),
+ .hasClass('rc-select-dropdown-empty'),
+ ).toBeFalsy();
+
+ const wrapper2 = mount();
+ expect(
+ wrapper2
+ .find('.rc-select-dropdown')
+ .first()
+ .hasClass('rc-select-dropdown-empty'),
).toBeTruthy();
});
it('should default select the right option', () => {
- const wrapper = mount(
+ const wrapper = mount(
,
);
- wrapper.find('.rc-select').simulate('click');
- expect((wrapper.find('Menu').props() as any).selectedKeys).toEqual(['2']);
+
+ toggleOpen(wrapper);
+ expect(
+ wrapper.find('.rc-select-item-option-selected div.rc-select-item-option-content').text(),
+ ).toBe('2');
});
it('should can select multiple items', () => {
- const wrapper = mount(
-
+ const wrapper = mount(
+
,
);
- wrapper.find('.rc-select').simulate('click');
- expect((wrapper.find('Menu').props() as any).selectedKeys).toEqual(['1', '2']);
+ toggleOpen(wrapper);
+ expect(
+ wrapper
+ .find('.rc-select-item-option-selected div.rc-select-item-option-content')
+ .map(node => node.text()),
+ ).toEqual(['1', '2']);
});
it('should hide clear button', () => {
- const wrapper = mount(
-
+ const wrapper1 = mount(
+
+
+
+ ,
+ );
+ expect(wrapper1.find('.rc-select-clear-icon').length).toBeTruthy();
+
+ const wrapper2 = mount(
+
,
);
- expect(wrapper.find('.rc-select-selection__clear').length).toBe(0);
+ expect(wrapper2.find('.rc-select-clear-icon').length).toBeFalsy();
});
it('should not response click event when select is disabled', () => {
- const wrapper = mount(
-
+ const wrapper = mount(
+
,
);
- wrapper.find('.rc-select').simulate('click');
- expect(wrapper.state().open).toBe(false);
+ toggleOpen(wrapper);
+ expectOpen(wrapper, false);
});
it('should show selected value in singleMode when close', () => {
- const wrapper = mount(
+ const wrapper = mount(
,
);
- expect(wrapper.find('.rc-select-selection-selected-value').props().children).toBe('1');
+ expect(findSelection(wrapper).text()).toBe('1');
});
it('filter options by "value" prop by default', () => {
- const wrapper = mount(
+ const wrapper = mount(
@@ -195,25 +237,25 @@ describe('Select', () => {
);
wrapper.find('input').simulate('change', { target: { value: '1' } });
- expect(wrapper.find('MenuItem').length).toBe(1);
- expect(wrapper.find('MenuItem').props().value).toBe('1');
+ expect(wrapper.find('List').props().data.length).toBe(1);
+ expect(wrapper.find('div.rc-select-item-option-content').text()).toBe('One');
});
it('should filter options when filterOption is true', () => {
- const wrapper = mount(
-
+ const wrapper = mount(
+
,
);
wrapper.find('input').simulate('change', { target: { value: '2' } });
- expect(wrapper.find('MenuItem').length).toBe(1);
- expect(wrapper.find('MenuItem').props().value).toBe('2');
+ expect(wrapper.find('List').props().data.length).toBe(1);
+ expect(wrapper.find('div.rc-select-item-option-content').text()).toBe('Two');
});
it('should not filter options when filterOption is false', () => {
- const wrapper = mount(
+ const wrapper = mount(
@@ -221,11 +263,11 @@ describe('Select', () => {
);
wrapper.find('input').simulate('change', { target: { value: '1' } });
- expect(wrapper.find('MenuItem').length).toBe(2);
+ expect(wrapper.find('List').props().data.length).toBe(2);
});
it('specify which prop to filter', () => {
- const wrapper = mount(
+ const wrapper = mount(