diff --git a/components/cascader/demo/search.md b/components/cascader/demo/search.md
new file mode 100644
index 0000000000..0df9ce8957
--- /dev/null
+++ b/components/cascader/demo/search.md
@@ -0,0 +1,50 @@
+---
+order: 9
+title:
+ zh-CN: 搜索
+ en-US: Search
+---
+
+## zh-CN
+
+可以直接搜索选项并选择。
+
+## en-US
+
+Search and select options directly.
+
+````jsx
+import { Cascader } from 'antd';
+
+const options = [{
+ value: 'zhejiang',
+ label: '浙江',
+ children: [{
+ value: 'hangzhou',
+ label: '杭州',
+ children: [{
+ value: 'xihu',
+ label: '西湖',
+ }],
+ }],
+}, {
+ value: 'jiangsu',
+ label: '江苏',
+ children: [{
+ value: 'nanjing',
+ label: '南京',
+ children: [{
+ value: 'zhonghuamen',
+ label: '中华门',
+ }],
+ }],
+}];
+
+function onChange(value) {
+ console.log(value);
+}
+
+ReactDOM.render(
+
+, mountNode);
+````
diff --git a/components/cascader/index.en-US.md b/components/cascader/index.en-US.md
index 4d8930f98d..cdfd6a023b 100644
--- a/components/cascader/index.en-US.md
+++ b/components/cascader/index.en-US.md
@@ -36,3 +36,15 @@ Cascade selection box.
| allowClear | whether allow clear | boolean | true |
| expandTrigger | expand current item when click or hover, one of 'click' 'hover' | string | 'click' |
| changeOnSelect | change value on each selection if set to true, see above demo for details | boolean | false |
+| showSearch | Whether show search input in single mode. | Boolean or Object | false |
+| notFoundContent | Specify content to show when no result matches. | String | 'Not Found' |
+
+Fields in `showSearch`:
+
+| Property | Description | Type | Default |
+|----------|-------------|------|---------|
+| filter | The function will receive two arguments, inputValue and option, if the function returns true, the option will be included in the filtered set; Otherwise, it will be excluded. | `function(inputValue, path): boolean` | |
+| render | Used to render filtered options. | `function(inputValue, path): React.ReactNode` | |
+| sort | Used to sort filtered options. | `function(a, b, inputValue)` | |
+| matchInputWidth | Whether the width of result list equals to input's | boolean | |
+
diff --git a/components/cascader/index.tsx b/components/cascader/index.tsx
index 0ed6df1c8f..fc23058077 100644
--- a/components/cascader/index.tsx
+++ b/components/cascader/index.tsx
@@ -15,6 +15,14 @@ export interface CascaderOptionType {
}
export type CascaderExpandTrigger = 'click' | 'hover'
+
+export interface ShowSearchType {
+ filter?: (inputValue: string, path: CascaderOptionType[]) => boolean;
+ render?: (inputValue: string, path: CascaderOptionType[]) => React.ReactNode;
+ sort?: (a: CascaderOptionType[], b: CascaderOptionType[], inputValue: string) => number;
+ matchInputWidth?: boolean;
+}
+
export interface CascaderProps {
/** 可选项数据源 */
options: Array;
@@ -42,6 +50,8 @@ export interface CascaderProps {
disabled?: boolean;
/** 是否支持清除*/
allowClear?: boolean;
+ showSearch?: boolean | ShowSearchType;
+ notFoundContent?: React.ReactNode;
/** 次级菜单的展开方式,可选 'click' 和 'hover' */
expandTrigger?: CascaderExpandTrigger;
/** 当此项为 true 时,点选每级菜单选项值都会发生变化 */
@@ -50,6 +60,33 @@ export interface CascaderProps {
onPopupVisibleChange?: (popupVisible: boolean) => void;
}
+function highlightKeyword(str: string, keyword: string) {
+ return str.split(keyword)
+ .map((node: string, index: number) => index === 0 ? node : [
+ {keyword},
+ node,
+ ]);
+}
+
+function defaultFilterOption(inputValue, path) {
+ return path.some(option => option.label.indexOf(inputValue) > -1);
+}
+
+function defaultRenderFilteredOption(inputValue, path) {
+ return path.map(({ label }, index) => {
+ const node = label.indexOf(inputValue) > -1 ? highlightKeyword(label, inputValue) : label;
+ return index === 0 ? node : [' / ', node];
+ });
+}
+
+function defaultSortFilteredOption(a, b, inputValue) {
+ function callback(elem) {
+ return elem.label.indexOf(inputValue) > -1;
+ }
+
+ return a.findIndex(callback) - b.findIndex(callback);
+}
+
export default class Cascader extends React.Component {
static defaultProps = {
prefixCls: 'ant-cascader',
@@ -61,20 +98,27 @@ export default class Cascader extends React.Component {
displayRender: label => label.join(' / '),
disabled: false,
allowClear: true,
+ showSearch: false,
+ notFoundContent: 'Not Found',
onPopupVisibleChange() {},
};
+ cachedOptions: CascaderOptionType[];
+ refs: {
+ [key: string]: any;
+ input: {
+ refs: { input: HTMLElement }
+ };
+ };
+
constructor(props) {
super(props);
- let value;
- if ('value' in props) {
- value = props.value;
- } else if ('defaultValue' in props) {
- value = props.defaultValue;
- }
this.state = {
- value: value || [],
+ value: props.value || props.defautValue || [],
+ inputValue: '',
+ inputFocused: false,
popupVisible: false,
+ flattenOptions: props.showSearch && this.flattenTree(props.options, props.changeOnSelect),
};
}
@@ -82,17 +126,45 @@ export default class Cascader extends React.Component {
if ('value' in nextProps) {
this.setState({ value: nextProps.value || [] });
}
+ if (nextProps.showSearch && this.props.options !== nextProps.options) {
+ this.setState({ flattenOptions: this.flattenTree(nextProps.options, nextProps.changeOnSelect) });
+ }
}
handleChange = (value, selectedOptions) => {
- this.setValue(value, selectedOptions);
+ const unwrappedValue = Array.isArray(value[0]) ? value[0] : value;
+ this.setState({ inputValue: '' });
+ this.setValue(unwrappedValue, selectedOptions);
}
handlePopupVisibleChange = (popupVisible) => {
- this.setState({ popupVisible });
+ this.setState({
+ popupVisible,
+ inputFocused: popupVisible,
+ });
this.props.onPopupVisibleChange(popupVisible);
}
+ handleInputBlur = () => {
+ this.setState({
+ inputFocused: false,
+ });
+ }
+
+ handleInputClick = (e) => {
+ const { inputFocused, popupVisible } = this.state;
+ // Prevent `Trigger` behaviour.
+ if (inputFocused || popupVisible) {
+ e.stopPropagation();
+ e.nativeEvent.stopImmediatePropagation();
+ }
+ }
+
+ handleInputChange = (e) => {
+ const inputValue = e.target.value;
+ this.setState({ inputValue });
+ }
+
setValue = (value, selectedOptions = []) => {
if (!('value' in this.props)) {
this.setState({ value });
@@ -102,7 +174,9 @@ export default class Cascader extends React.Component {
getLabel() {
const { options, displayRender } = this.props;
- const selectedOptions = arrayTreeFilter(options, (o, level) => o.value === this.state.value[level]);
+ const value = this.state.value;
+ const unwrappedValue = Array.isArray(value[0]) ? value[0] : value;
+ const selectedOptions = arrayTreeFilter(options, (o, level) => o.value === unwrappedValue[level]);
const label = selectedOptions.map(o => o.label);
return displayRender(label, selectedOptions);
}
@@ -110,28 +184,71 @@ export default class Cascader extends React.Component {
clearSelection = (e) => {
e.preventDefault();
e.stopPropagation();
- this.setValue([]);
- this.setState({ popupVisible: false });
+ if (!this.state.inputValue) {
+ this.setValue([]);
+ this.setState({ popupVisible: false });
+ } else {
+ this.setState({ inputValue: '' });
+ }
+ }
+
+ flattenTree(options, changeOnSelect, ancestor = []) {
+ let flattenOptions = [];
+ options.forEach((option) => {
+ const path = ancestor.concat(option);
+ if (changeOnSelect || !option.children) {
+ flattenOptions.push(path);
+ }
+ if (option.children) {
+ flattenOptions = flattenOptions.concat(this.flattenTree(option.children, changeOnSelect, path));
+ }
+ });
+ return flattenOptions;
+ }
+
+ generateFilteredOptions() {
+ const { showSearch, notFoundContent } = this.props;
+ const {
+ filter = defaultFilterOption,
+ render = defaultRenderFilteredOption,
+ sort = defaultSortFilteredOption,
+ } = showSearch as ShowSearchType;
+ const { flattenOptions, inputValue } = this.state;
+ const filtered = flattenOptions.filter((path) => filter(this.state.inputValue, path))
+ .sort((a, b) => sort(a, b, inputValue));
+
+ if (filtered.length > 0) {
+ return filtered.map((path) => {
+ return {
+ label: render(inputValue, path),
+ value: path.map(o => o.value),
+ };
+ });
+ }
+ return [{ label: notFoundContent, value: 'ANT_CASCADER_NOT_FOUND', disabled: true }];
}
render() {
const props = this.props;
+ const state = this.state;
const [{ prefixCls, children, placeholder, size, disabled,
- className, style, allowClear }, otherProps] = splitObject(props,
- ['prefixCls', 'children', 'placeholder', 'size', 'disabled', 'className', 'style', 'allowClear']);
+ className, style, allowClear, showSearch }, otherProps] = splitObject(props,
+ ['prefixCls', 'children', 'placeholder', 'size', 'disabled', 'className',
+ 'style', 'allowClear', 'showSearch']);
+ const value = state.value;
const sizeCls = classNames({
'ant-input-lg': size === 'large',
'ant-input-sm': size === 'small',
});
- const clearIcon = (allowClear && !disabled && this.state.value.length > 0) ?
+ const clearIcon = (allowClear && !disabled && value.length > 0) || state.inputValue ?
: null;
const arrowCls = classNames({
[`${prefixCls}-picker-arrow`]: true,
- [`${prefixCls}-picker-arrow-expand`]: this.state.popupVisible,
+ [`${prefixCls}-picker-arrow-expand`]: state.popupVisible,
});
const pickerCls = classNames({
[className]: !!className,
@@ -154,14 +271,44 @@ export default class Cascader extends React.Component {
'getPopupContainer',
'loadData',
'popupClassName',
+ 'filterOption',
+ 'renderFilteredOption',
+ 'sortFilteredOption',
+ 'notFoundContent',
]);
+ let options = props.options;
+ if (state.inputValue) {
+ options = this.generateFilteredOptions();
+ }
+ // Dropdown menu should keep previous status until it is fully closed.
+ if (!state.popupVisible) {
+ options = this.cachedOptions;
+ } else {
+ this.cachedOptions = options;
+ }
+
+ const dropdownMenuColumnStyle = {
+ width: undefined,
+ height: undefined,
+ };
+ const isNotFound = (options || []).length === 1 && options[0].value === 'ANT_CASCADER_NOT_FOUND';
+ if (isNotFound) {
+ dropdownMenuColumnStyle.height = 'auto'; // Height of one row.
+ }
+ // The default value of `matchInputWidth` is `true`
+ const resultListMatchInputWidth = showSearch.matchInputWidth === false ? false : true;
+ if (resultListMatchInputWidth && state.inputValue && this.refs.input) {
+ dropdownMenuColumnStyle.width = this.refs.input.refs.input.offsetWidth;
+ }
return (
{children ||
{
className={pickerCls}
>
0 ? null : placeholder}
+ ref="input"
+ placeholder={value && value.length > 0 ? null : placeholder}
className={`${prefixCls}-input ${sizeCls}`}
- value=""
+ value={state.inputValue}
disabled={disabled}
- readOnly
+ readOnly={!showSearch}
+ onClick={showSearch ? this.handleInputClick : null}
+ onBlur={showSearch ? this.handleInputBlur : null}
+ onChange={showSearch ? this.handleInputChange : null}
/>
{this.getLabel()}
{clearIcon}
diff --git a/components/cascader/index.zh-CN.md b/components/cascader/index.zh-CN.md
index 30029be949..eaa79b54cf 100644
--- a/components/cascader/index.zh-CN.md
+++ b/components/cascader/index.zh-CN.md
@@ -1,8 +1,8 @@
---
category: Components
type: Form Controls
-chinese: 级联选择
-english: Cascader
+title: Cascader
+subtitle: 级联选择
---
级联选择框。
@@ -22,18 +22,29 @@ english: Cascader
| 参数 | 说明 | 类型 | 默认值 |
|------|------|------|--------|
-| options | 可选项数据源 | object | - |
-| defaultValue | 默认的选中项 | array |[] |
-| value | 指定选中项 | array | - |
+| options | 可选项数据源 | Object | - |
+| defaultValue | 默认的选中项 | Array |[] |
+| value | 指定选中项 | Array | - |
| onChange | 选择完成后的回调 | `function(value, selectedOptions)` | - |
| displayRender | 选择后展示的渲染函数 | `function(label, selectedOptions)` | `label => label.join(' / ')` |
-| style | 自定义样式 | string | - |
-| className | 自定义类名 | string | - |
-| popupClassName | 自定义浮层类名 | string | - |
-| popupPlacement | 浮层预设位置:`bottomLeft` `bottomRight` `topLeft` `topRight` | string | `bottomLeft` |
-| placeholder | 输入框占位文本 | string | '请选择' |
-| size | 输入框大小,可选 `large` `default` `small` | string | `default` |
-| disabled | 禁用 | boolean | false |
-| allowClear | 是否支持清除 | boolean | true |
-| expandTrigger | 次级菜单的展开方式,可选 'click' 和 'hover' | string | 'click' |
-| changeOnSelect | 当此项为 true 时,点选每级菜单选项值都会发生变化,具体见上面的演示 | boolean | false |
+| style | 自定义样式 | String | - |
+| className | 自定义类名 | String | - |
+| popupClassName | 自定义浮层类名 | String | - |
+| popupPlacement | 浮层预设位置:`bottomLeft` `bottomRight` `topLeft` `topRight` | Enum | `bottomLeft` |
+| placeholder | 输入框占位文本 | String | '请选择' |
+| size | 输入框大小,可选 `large` `default` `small` | String | `default` |
+| disabled | 禁用 | Boolean | false |
+| allowClear | 是否支持清除 | Boolean | true |
+| expandTrigger | 次级菜单的展开方式,可选 'click' 和 'hover' | String | 'click' |
+| changeOnSelect | 当此项为 true 时,点选每级菜单选项值都会发生变化,具体见上面的演示 | Boolean | false |
+| showSearch | 在选择框中显示搜索框 | Boolean | false |
+| notFoundContent | 当下拉列表为空时显示的内容 | String | 'Not Found' |
+
+`showSearch` 为对象时,其中的字段:
+
+| 参数 | 说明 | 类型 | 默认值 |
+|------|------|------|--------|
+| filter | 接收 `inputValue` `path` 两个参数,当 `path` 符合筛选条件时,应返回 true,反之则返回 false。 | `function(inputValue, path): boolean` | |
+| render | 用于渲染 filter 后的选项 | `function(inputValue, path): React.ReactNode` | |
+| sort | 用于排序 filter 后的选项 | `function(a, b, inputValue)` | |
+| matchInputWidth | 搜索结果列表是否与输入框同宽 | boolean | |
diff --git a/components/cascader/style/index.less b/components/cascader/style/index.less
index 5e12146ff8..8be6ae5076 100644
--- a/components/cascader/style/index.less
+++ b/components/cascader/style/index.less
@@ -6,6 +6,7 @@
.@{cascader-prefix-cls} {
font-size: @font-size-base;
&-input.ant-input {
+ background-color: transparent;
display: block;
cursor: pointer;
width: 100%;
@@ -22,6 +23,10 @@
&-disabled {
cursor: not-allowed;
+
+ .@{cascader-prefix-cls}-input {
+ cursor: not-allowed;
+ }
}
&-label {
@@ -36,7 +41,6 @@
overflow: hidden;
width: 100%;
padding: 0 12px 0 8px;
- z-index: 1;
}
&-clear {
@@ -171,5 +175,9 @@
right: 15px;
}
}
+
+ & &-keyword {
+ color: @error-color;
+ }
}
}
diff --git a/components/input/Input.tsx b/components/input/Input.tsx
index fbfbbd3192..25792b4b7a 100644
--- a/components/input/Input.tsx
+++ b/components/input/Input.tsx
@@ -48,6 +48,8 @@ export interface InputProps {
onPressEnter?: React.FormEventHandler;
onKeyDown?: React.FormEventHandler;
onChange?: React.FormEventHandler;
+ onClick?: React.FormEventHandler;
+ onBlur?: React.FormEventHandler;
autosize?: boolean | AutoSizeType;
}