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; }