diff --git a/components/cascader/__tests__/index.test.js b/components/cascader/__tests__/index.test.js index 5fa263465..f14d35d58 100644 --- a/components/cascader/__tests__/index.test.js +++ b/components/cascader/__tests__/index.test.js @@ -4,6 +4,9 @@ import KeyCode from '../../_util/KeyCode' import Cascader from '..' import focusTest from '../../../tests/shared/focusTest' +function $$ (className) { + return document.body.querySelectorAll(className) +} const options = [{ value: 'zhejiang', label: 'Zhejiang', @@ -28,6 +31,10 @@ const options = [{ }], }] +function filter (inputValue, path) { + return path.some(option => option.label.toLowerCase().indexOf(inputValue.toLowerCase()) > -1) +} + describe('Cascader', () => { focusTest(Cascader) @@ -187,4 +194,60 @@ describe('Cascader', () => { expect(wrapper.vm.inputValue).toBe('123') }) }) + + describe('limit filtered item count', () => { + beforeEach(() => { + document.body.outerHTML = '' + }) + + afterEach(() => { + document.body.outerHTML = '' + }) + + it('limit with positive number', async () => { + const wrapper = mount(Cascader, { + propsData: { options, showSearch: { filter, limit: 1 }}, + sync: false, + attachToDocument: true, + }) + wrapper.find('input').trigger('click') + wrapper.find('input').element.value = 'a' + wrapper.find('input').trigger('input') + await asyncExpect(() => { + expect($$('.ant-cascader-menu-item').length).toBe(1) + }, 0) + }) + + it('not limit', async () => { + const wrapper = mount(Cascader, { + propsData: { options, showSearch: { filter, limit: false }}, + sync: false, + attachToDocument: true, + }) + wrapper.find('input').trigger('click') + wrapper.find('input').element.value = 'a' + wrapper.find('input').trigger('input') + await asyncExpect(() => { + expect($$('.ant-cascader-menu-item').length).toBe(2) + }, 0) + }) + + it('negative limit', async () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + const wrapper = mount(Cascader, { + propsData: { options, showSearch: { filter, limit: -1 }}, + sync: false, + attachToDocument: true, + }) + wrapper.find('input').trigger('click') + wrapper.find('input').element.value = 'a' + wrapper.find('input').trigger('input') + await asyncExpect(() => { + expect($$('.ant-cascader-menu-item').length).toBe(2) + }, 0) + expect(errorSpy).toBeCalledWith( + "Warning: 'limit' of showSearch in Cascader should be positive number or false.", + ) + }) + }) }) diff --git a/components/cascader/index.en-US.md b/components/cascader/index.en-US.md index 386438080..231ccfa60 100644 --- a/components/cascader/index.en-US.md +++ b/components/cascader/index.en-US.md @@ -34,6 +34,7 @@ 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` | | +| limit | Set the count of filtered items | number \| false | 50 | | matchInputWidth | Whether the width of result list equals to input's | boolean | | | render | Used to render filtered options, you can use slot="showSearchRender" and slot-scope="{inputValue, path}" | `function({inputValue, path}): vNode` | | | sort | Used to sort filtered options. | `function(a, b, inputValue)` | | diff --git a/components/cascader/index.jsx b/components/cascader/index.jsx index 9c7620c40..158918a88 100644 --- a/components/cascader/index.jsx +++ b/components/cascader/index.jsx @@ -10,6 +10,7 @@ import Icon from '../icon' import { hasProp, filterEmpty, getOptionProps, getStyle, getClass, getAttrs, getComponentFromProp, isValidElement } from '../_util/props-util' import BaseMixin from '../_util/BaseMixin' import { cloneElement } from '../_util/vnode' +import warning from '../_util/warning' const CascaderOptionType = PropTypes.shape({ value: PropTypes.string, @@ -32,6 +33,7 @@ const ShowSearchType = PropTypes.shape({ render: PropTypes.func, sort: PropTypes.func, matchInputWidth: PropTypes.bool, + limit: PropTypes.oneOfType([Boolean, Number]), }).loose function noop () {} @@ -78,6 +80,9 @@ const CascaderProps = { suffixIcon: PropTypes.any, } +// We limit the filtered item count by default +const defaultLimit = 50 + function defaultFilterOption (inputValue, path, names) { return path.some(option => option[names.label].indexOf(inputValue) > -1) } @@ -99,6 +104,26 @@ function getFilledFieldNames ({ fieldNames = {}}) { return names } +function flattenTree ( + options = [], + props, + ancestor = [], +) { + const names = getFilledFieldNames(props) + let flattenOptions = [] + const childrenName = names.children + options.forEach(option => { + const path = ancestor.concat(option) + if (props.changeOnSelect || !option[childrenName] || !option[childrenName].length) { + flattenOptions.push(path) + } + if (option[childrenName]) { + flattenOptions = flattenOptions.concat(flattenTree(option[childrenName], props, path)) + } + }) + return flattenOptions +} + const defaultDisplayRender = ({ labels }) => labels.join(' / ') const Cascader = { @@ -110,9 +135,13 @@ const Cascader = { prop: 'value', event: 'change', }, + inject: { + configProvider: { default: {}}, + localeData: { default: {}}, + }, data () { this.cachedOptions = [] - const { value, defaultValue, popupVisible, showSearch, options, flattenTree } = this + const { value, defaultValue, popupVisible, showSearch, options } = this return { sValue: value || defaultValue || [], inputValue: '', @@ -137,7 +166,7 @@ const Cascader = { }, options (val) { if (this.showSearch) { - this.setState({ flattenOptions: this.flattenTree(this.options, this.$props) }) + this.setState({ flattenOptions: flattenTree(val, this.$props) }) } }, }, @@ -171,11 +200,11 @@ const Cascader = { handlePopupVisibleChange (popupVisible) { if (!hasProp(this, 'popupVisible')) { - this.setState({ + this.setState(state => ({ sPopupVisible: popupVisible, inputFocused: popupVisible, - inputValue: popupVisible ? this.inputValue : '', - }) + inputValue: popupVisible ? state.inputValue : '', + })) } this.$emit('popupVisibleChange', popupVisible) }, @@ -244,24 +273,6 @@ const Cascader = { } }, - flattenTree (options, props, ancestor = []) { - const names = getFilledFieldNames(props) - let flattenOptions = [] - const childrenName = names.children - options.forEach((option) => { - const path = ancestor.concat(option) - if (props.changeOnSelect || !option[childrenName] || !option[childrenName].length) { - flattenOptions.push(path) - } - if (option[childrenName]) { - flattenOptions = flattenOptions.concat( - this.flattenTree(option[childrenName], props, path) - ) - } - }) - return flattenOptions - }, - generateFilteredOptions (prefixCls) { const { showSearch, notFoundContent, $scopedSlots } = this const names = getFilledFieldNames(this.$props) @@ -269,11 +280,35 @@ const Cascader = { filter = defaultFilterOption, // render = this.defaultRenderFilteredOption, sort = defaultSortFilteredOption, + limit = defaultLimit, } = showSearch - const { flattenOptions = [], inputValue } = this.$data const render = showSearch.render || $scopedSlots.showSearchRender || this.defaultRenderFilteredOption - const filtered = flattenOptions.filter((path) => filter(inputValue, path, names)) - .sort((a, b) => sort(a, b, inputValue, names)) + const { flattenOptions = [], inputValue } = this.$data + + // Limit the filter if needed + let filtered + if (limit > 0) { + filtered = [] + let matchCount = 0 + + // Perf optimization to filter items only below the limit + flattenOptions.some(path => { + const match = filter(inputValue, path, names) + if (match) { + filtered.push(path) + matchCount += 1 + } + return matchCount >= limit + }) + } else { + warning( + typeof limit !== 'number', + "'limit' of showSearch in Cascader should be positive number or false.", + ) + filtered = flattenOptions.filter(path => filter(inputValue, path, names)) + } + + filtered.sort((a, b) => sort(a, b, inputValue, names)) if (filtered.length > 0) { return filtered.map((path) => { @@ -307,14 +342,22 @@ const Cascader = { }, render () { - const { $slots, sPopupVisible, inputValue, $listeners } = this + const { $slots, sPopupVisible, inputValue, $listeners, configProvider, localeData } = this const { sValue: value, inputFocused } = this.$data const props = getOptionProps(this) let suffixIcon = getComponentFromProp(this, 'suffixIcon') suffixIcon = Array.isArray(suffixIcon) ? suffixIcon[0] : suffixIcon + const { getPopupContainer: getContextPopupContainer } = configProvider const { - prefixCls, inputPrefixCls, placeholder, size, disabled, - allowClear, showSearch = false, ...otherProps } = props + prefixCls, + inputPrefixCls, + placeholder = localeData.placeholder, + size, + disabled, + allowClear, + showSearch = false, + ...otherProps + } = props const sizeCls = classNames({ [`${inputPrefixCls}-lg`]: size === 'large', @@ -448,9 +491,11 @@ const Cascader = { ) + const getPopupContainer = props.getPopupContainer || getContextPopupContainer const cascaderProps = { props: { ...props, + getPopupContainer, options: options, value: value, popupVisible: sPopupVisible, diff --git a/components/cascader/index.zh-CN.md b/components/cascader/index.zh-CN.md index 1a0d58d17..19e4a9970 100644 --- a/components/cascader/index.zh-CN.md +++ b/components/cascader/index.zh-CN.md @@ -34,6 +34,7 @@ | 参数 | 说明 | 类型 | 默认值 | | --- | --- | --- | --- | | filter | 接收 `inputValue` `path` 两个参数,当 `path` 符合筛选条件时,应返回 true,反之则返回 false。 | `function(inputValue, path): boolean` | | +| limit | 搜索结果展示数量 | number \| false | 50 | | matchInputWidth | 搜索结果列表是否与输入框同宽 | boolean | | | render | 用于渲染 filter 后的选项,可使用slot="showSearchRender" 和 slot-scope="{inputValue, path}" | `function({inputValue, path}): vNode` | | | sort | 用于排序 filter 后的选项 | `function(a, b, inputValue)` | |