mirror of
https://github.com/ant-design/ant-design.git
synced 2025-01-18 14:13:37 +08:00
feat: Cascader[showSearch] (#2776)
* feat: implement Cascader[showSearch] basicly * feat: optimize Cascader[showSearch] * feat: clear for input value * feat: complete Cascader[showSearchf] * docs: add docs for Cascader[search] * fix: review * fix: add key * style: update code style to please lint * refactor: update according to review * docs: update docs
This commit is contained in:
parent
04e8f062c7
commit
92ab804815
50
components/cascader/demo/search.md
Normal file
50
components/cascader/demo/search.md
Normal file
@ -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(
|
||||
<Cascader options={options} onChange={onChange} placeholder="请选择地区" showSearch />
|
||||
, mountNode);
|
||||
````
|
@ -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 | |
|
||||
|
||||
|
@ -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<CascaderOptionType>;
|
||||
@ -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 : [
|
||||
<span className="ant-cascader-menu-item-keyword" key="seperator">{keyword}</span>,
|
||||
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<CascaderProps, any> {
|
||||
static defaultProps = {
|
||||
prefixCls: 'ant-cascader',
|
||||
@ -61,20 +98,27 @@ export default class Cascader extends React.Component<CascaderProps, any> {
|
||||
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<CascaderProps, any> {
|
||||
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<CascaderProps, any> {
|
||||
|
||||
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<CascaderProps, any> {
|
||||
clearSelection = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
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 ?
|
||||
<Icon type="cross-circle"
|
||||
className={`${prefixCls}-picker-clear`}
|
||||
onClick={this.clearSelection}
|
||||
/> : 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<CascaderProps, any> {
|
||||
'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 (
|
||||
<RcCascader {...props}
|
||||
value={this.state.value}
|
||||
popupVisible={this.state.popupVisible}
|
||||
options={options}
|
||||
value={value}
|
||||
popupVisible={state.popupVisible}
|
||||
onPopupVisibleChange={this.handlePopupVisibleChange}
|
||||
onChange={this.handleChange}
|
||||
dropdownMenuColumnStyle={dropdownMenuColumnStyle}
|
||||
>
|
||||
{children ||
|
||||
<span
|
||||
@ -169,11 +316,15 @@ export default class Cascader extends React.Component<CascaderProps, any> {
|
||||
className={pickerCls}
|
||||
>
|
||||
<Input {...inputProps}
|
||||
placeholder={this.state.value && this.state.value.length > 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}
|
||||
/>
|
||||
<span className={`${prefixCls}-picker-label`}>{this.getLabel()}</span>
|
||||
{clearIcon}
|
||||
|
@ -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 | |
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user