From 3e8e90da5e6931049944112c762d6a1cb0c37636 Mon Sep 17 00:00:00 2001 From: tangjinzhou <415800467@qq.com> Date: Sun, 23 Feb 2020 16:16:19 +0800 Subject: [PATCH] perf: update select --- build/config.js | 2 +- .../__tests__/__snapshots__/demo.test.js.snap | 20 ++- components/select/__tests__/index.test.js | 2 + .../select/demo/custom-dropdown-menu.md | 14 +- components/select/demo/index.vue | 2 + components/select/demo/option-label-prop.md | 60 ++++++++ components/select/index.en-US.md | 7 + components/select/index.jsx | 23 +-- components/select/index.zh-CN.md | 7 + components/vc-select/DropdownMenu.jsx | 6 +- components/vc-select/Select.jsx | 139 ++++++++++++------ components/vc-select/SelectTrigger.jsx | 20 ++- components/vc-select/index.js | 2 +- 13 files changed, 232 insertions(+), 72 deletions(-) create mode 100644 components/select/demo/option-label-prop.md diff --git a/build/config.js b/build/config.js index cce4d0f4a..b9f68ea9a 100644 --- a/build/config.js +++ b/build/config.js @@ -1,5 +1,5 @@ module.exports = { dev: { - componentName: 'steps', // dev components + componentName: 'select', // dev components }, }; diff --git a/components/select/__tests__/__snapshots__/demo.test.js.snap b/components/select/__tests__/__snapshots__/demo.test.js.snap index e25c2b860..c4ad49a00 100644 --- a/components/select/__tests__/__snapshots__/demo.test.js.snap +++ b/components/select/__tests__/__snapshots__/demo.test.js.snap @@ -63,7 +63,7 @@ exports[`renders ./components/select/demo/custom-dropdown-menu.md correctly 1`]
-
Lucy
+
lucy
@@ -125,6 +125,24 @@ exports[`renders ./components/select/demo/optgroup.md correctly 1`] = ` `; +exports[`renders ./components/select/demo/option-label-prop.md correctly 1`] = ` +
+
+
+ +
+ + +
+
+
+
+`; + exports[`renders ./components/select/demo/options.md correctly 1`] = `
diff --git a/components/select/__tests__/index.test.js b/components/select/__tests__/index.test.js index e15f0c926..19fa6f06a 100644 --- a/components/select/__tests__/index.test.js +++ b/components/select/__tests__/index.test.js @@ -3,9 +3,11 @@ import { asyncExpect } from '@/tests/utils'; import Select from '..'; import Icon from '../../icon'; import focusTest from '../../../tests/shared/focusTest'; +import mountTest from '../../../tests/shared/mountTest'; describe('Select', () => { focusTest(Select); + mountTest(Select); it('should have default notFoundContent', async () => { const wrapper = mount(Select, { diff --git a/components/select/demo/custom-dropdown-menu.md b/components/select/demo/custom-dropdown-menu.md index b1957560f..a333599ea 100644 --- a/components/select/demo/custom-dropdown-menu.md +++ b/components/select/demo/custom-dropdown-menu.md @@ -14,21 +14,27 @@ Customize the dropdown menu via `dropdownRender`.
-
Add item
+
Add item
- Jack - Lucy + {{item}} ``` diff --git a/components/select/demo/index.vue b/components/select/demo/index.vue index 03fae3114..a8ee714e0 100644 --- a/components/select/demo/index.vue +++ b/components/select/demo/index.vue @@ -13,6 +13,7 @@ import SelectUsers from './select-users'; import Suffix from './suffix'; import HideSelected from './hide-selected'; import CustomDropdownMenu from './custom-dropdown-menu'; +import OptionLabelProp from './option-label-prop'; import CN from '../index.zh-CN.md'; import US from '../index.en-US.md'; @@ -54,6 +55,7 @@ export default { + diff --git a/components/select/demo/option-label-prop.md b/components/select/demo/option-label-prop.md new file mode 100644 index 000000000..67472237b --- /dev/null +++ b/components/select/demo/option-label-prop.md @@ -0,0 +1,60 @@ + +#### 定制回填内容 +使用 `optionLabelProp` 指定回填到选择框的 `Option` 属性。 + + + +#### Custom selection render +Spacified the prop name of Option which will be rendered in select box. + + +```tpl + + +``` diff --git a/components/select/index.en-US.md b/components/select/index.en-US.md index fbd9ab587..9f3b77e61 100644 --- a/components/select/index.en-US.md +++ b/components/select/index.en-US.md @@ -20,6 +20,7 @@ | dropdownMatchSelectWidth | Whether dropdown's width is same with select. | boolean | true | | dropdownRender | Customize dropdown content | (menuNode: VNode, props) => VNode | - | | dropdownStyle | style of dropdown menu | object | - | +| dropdownMenuStyle | additional style applied to dropdown menu | object | - | | filterOption | If true, filter options by input, if function, filter options against it. 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. | boolean or function(inputValue, option) | true | | firstActiveValue | Value of action option by default | string\|string\[] | - | | getPopupContainer | Parent Node which the selector should be rendered to. Default to `body`. When position issues happen, try to modify it into scrollable content and position it relative. | function(triggerNode) | () => document.body | @@ -85,3 +86,9 @@ | -------- | ----------- | ------------ | ------- | | key | | string | - | | label | Group label | string\|slot | - | + +## FAQ + +### The dropdown is closed when click `dropdownRender` area? + +See the [dropdownRender example](/components/select/#components-select-demo-custom-dropdown). diff --git a/components/select/index.jsx b/components/select/index.jsx index 80d492a63..706c9886a 100644 --- a/components/select/index.jsx +++ b/components/select/index.jsx @@ -118,21 +118,13 @@ const Select = { created() { warning( this.$props.mode !== 'combobox', + 'Select', 'The combobox mode of Select is deprecated,' + 'it will be removed in next major version,' + 'please use AutoComplete instead', ); }, methods: { - savePopupRef(ref) { - this.popupRef = ref; - }, - focus() { - this.$refs.vcSelect.focus(); - }, - blur() { - this.$refs.vcSelect.blur(); - }, getNotFoundContent(renderEmpty) { const h = this.$createElement; const notFoundContent = getComponentFromProp(this, 'notFoundContent'); @@ -144,6 +136,16 @@ const Select = { } return renderEmpty(h, 'Select'); }, + savePopupRef(ref) { + this.popupRef = ref; + }, + focus() { + this.$refs.vcSelect.focus(); + }, + blur() { + this.$refs.vcSelect.blur(); + }, + isCombobox() { const { mode } = this; return mode === 'combobox' || mode === SECRET_COMBOBOX_MODE_DO_NOT_USE; @@ -171,6 +173,7 @@ const Select = { mode, options, getPopupContainer, + showArrow, ...restProps } = getOptionProps(this); @@ -198,6 +201,7 @@ const Select = { const cls = { [`${prefixCls}-lg`]: size === 'large', [`${prefixCls}-sm`]: size === 'small', + [`${prefixCls}-show-arrow`]: showArrow, }; let { optionLabelProp } = this.$props; @@ -234,6 +238,7 @@ const Select = { removeIcon: finalRemoveIcon, clearIcon: finalClearIcon, menuItemSelectedIcon: finalMenuItemSelectedIcon, + showArrow, ...rest, ...modeConfig, prefixCls, diff --git a/components/select/index.zh-CN.md b/components/select/index.zh-CN.md index f1e1a5230..e7fdd3131 100644 --- a/components/select/index.zh-CN.md +++ b/components/select/index.zh-CN.md @@ -20,6 +20,7 @@ | dropdownMatchSelectWidth | 下拉菜单和选择器同宽 | boolean | true | | dropdownRender | 自定义下拉框内容 | (menuNode: VNode, props) => VNode | - | | dropdownStyle | 下拉菜单的 style 属性 | object | - | +| dropdownMenuStyle | dropdown 菜单自定义样式 | object | - | | filterOption | 是否根据输入项进行筛选。当其为一个函数时,会接收 `inputValue` `option` 两个参数,当 `option` 符合筛选条件时,应返回 `true`,反之则返回 `false`。 | boolean or function(inputValue, option) | true | | firstActiveValue | 默认高亮的选项 | string\|string\[] | - | | getPopupContainer | 菜单渲染父节点。默认渲染到 body 上,如果你遇到菜单滚动定位问题,试试修改为滚动的区域,并相对其定位。 | Function(triggerNode) | () => document.body | @@ -86,3 +87,9 @@ | ----- | ---- | --------------------------- | ------ | | key | | string | - | | label | 组名 | string\|\|function(h)\|slot | 无 | + +## FAQ + +### 点击 `dropdownRender` 里的内容浮层关闭怎么办? + +看下 [dropdownRender 例子](/components/select-cn/#components-select-demo-custom-dropdown) 里的说明。 diff --git a/components/vc-select/DropdownMenu.jsx b/components/vc-select/DropdownMenu.jsx index 52ccfca50..ee68e56df 100644 --- a/components/vc-select/DropdownMenu.jsx +++ b/components/vc-select/DropdownMenu.jsx @@ -37,7 +37,7 @@ export default { }, created() { - this.rafInstance = { cancel: () => null }; + this.rafInstance = null; this.lastInputValue = this.$props.inputValue; this.lastVisible = false; }, @@ -60,8 +60,8 @@ export default { this.prevVisible = this.visible; }, beforeDestroy() { - if (this.rafInstance && this.rafInstance.cancel) { - this.rafInstance.cancel(); + if (this.rafInstance) { + raf.cancel(this.rafInstance); } }, methods: { diff --git a/components/vc-select/Select.jsx b/components/vc-select/Select.jsx index fd8ca58d8..50235ff63 100644 --- a/components/vc-select/Select.jsx +++ b/components/vc-select/Select.jsx @@ -59,6 +59,11 @@ const SELECT_EMPTY_VALUE_KEY = 'RC_SELECT_EMPTY_VALUE_KEY'; const noop = () => null; +// Where el is the DOM element you'd like to test for visibility +function isHidden(node) { + return !node || node.offsetParent === null; +} + function chaining(...fns) { return function(...args) { // eslint-disable-line @@ -130,6 +135,13 @@ const Select = { this.__propsSymbol__, 'Replace slots.default with props.children and pass props.__propsSymbol__', ); + 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.', + ); + } const state = { _value: this.getValueFromProps(props, true), // true: use default value _inputValue: props.combobox @@ -191,6 +203,7 @@ const Select = { beforeDestroy() { this.clearFocusTime(); this.clearBlurTime(); + this.clearComboboxTime(); if (this.dropdownContainer) { document.body.removeChild(this.dropdownContainer); this.dropdownContainer = null; @@ -326,7 +339,7 @@ const Select = { if (nextValue !== undefined) { this.fireChange(nextValue); } - this.setOpenState(false, true); + this.setOpenState(false, { needFocus: true }); this.setInputValue('', false); return; } @@ -378,14 +391,14 @@ const Select = { }, onInputKeydown(event) { - const props = this.$props; - if (props.disabled) { + const { disabled, combobox, defaultActiveFirstOption } = this.$props; + if (disabled) { return; } const state = this.$data; const isRealOpen = this.getRealOpenState(state); const keyCode = event.keyCode; - if (isMultipleOrTags(props) && !event.target.value && keyCode === KeyCode.BACKSPACE) { + if (isMultipleOrTags(this.$props) && !event.target.value && keyCode === KeyCode.BACKSPACE) { event.preventDefault(); const { _value: value } = state; if (value.length) { @@ -407,6 +420,12 @@ const Select = { if (isRealOpen || !props.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); @@ -433,12 +452,14 @@ const Select = { const props = this.$props; const selectedValue = getValuePropValue(item); const lastValue = value[value.length - 1]; - this.fireSelect(selectedValue); + let skipTrigger = false; + if (isMultipleOrTags(props)) { if (findIndexInValueBySingleValue(value, selectedValue) !== -1) { - return; + skipTrigger = true; + } else { + value = value.concat([selectedValue]); } - value = value.concat([selectedValue]); } else { if ( !isCombobox(props) && @@ -446,30 +467,40 @@ const Select = { lastValue === selectedValue && selectedValue !== this.$data._backfillValue ) { - this.setOpenState(false, true); - return; + this.setOpenState(false, { needFocus: true, fireSearch: false }); + skipTrigger = true; + } else { + value = [selectedValue]; + this.setOpenState(false, { needFocus: true, fireSearch: false }); } - value = [selectedValue]; - this.setOpenState(false, true); } - this.fireChange(value); - const inputValue = isCombobox(props) ? getPropValue(item, props.optionLabelProp) : ''; + if (!skipTrigger) { + this.fireChange(value); + } + if (!skipTrigger) { + this.fireSelect(selectedValue); + const inputValue = isCombobox(props) ? getPropValue(item, props.optionLabelProp) : ''; - if (props.autoClearSearchValue) { - this.setInputValue(inputValue, false); + if (props.autoClearSearchValue) { + this.setInputValue(inputValue, false); + } } }, onMenuDeselect({ item, domEvent }) { if (domEvent.type === 'keydown' && domEvent.keyCode === KeyCode.ENTER) { - this.removeSelected(getValuePropValue(item)); + const menuItemDomNode = item.$el; + // https://github.com/ant-design/ant-design/issues/20465#issuecomment-569033796 + if (!isHidden(menuItemDomNode)) { + this.removeSelected(getValuePropValue(item)); + } return; } if (domEvent.type === 'click') { this.removeSelected(getValuePropValue(item)); } if (this.autoClearSearchValue) { - this.setInputValue('', false); + this.setInputValue(''); } }, @@ -478,7 +509,7 @@ const Select = { e.preventDefault(); this.clearBlurTime(); if (!this.disabled) { - this.setOpenState(!this.$data._open, !this.$data._open); + this.setOpenState(!this.$data._open, { needFocus: !this.$data._open }); } }, @@ -505,7 +536,7 @@ const Select = { if (value.length) { this.fireChange([]); } - this.setOpenState(false, true); + this.setOpenState(false, { needFocus: true }); if (inputValue) { this.setInputValue(''); } @@ -527,9 +558,12 @@ const Select = { } let defaultLabel = value; if (this.$props.labelInValue) { - const label = getLabelFromPropsValue(this.$props.value, value); - if (label !== undefined) { - defaultLabel = label; + 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 = { @@ -734,7 +768,18 @@ const Select = { return; } this.clearBlurTime(); - if (!isMultipleOrTagsOrCombobox(this.$props) && e.target === this.getInputDOMNode()) { + + // 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) { @@ -837,8 +882,9 @@ const Select = { } }, - setOpenState(open, needFocus) { + setOpenState(open, config = {}) { const { $props: props, $data: state } = this; + const { needFocus, fireSearch } = config; if (state._open === open) { this.maybeFocus(open, !!needFocus); return; @@ -850,7 +896,7 @@ const Select = { }; // clear search input value when open is false in singleMode. if (!open && isSingleMode(props) && props.showSearch) { - this.setInputValue('', false); + this.setInputValue('', fireSearch); } if (!open) { this.maybeFocus(open, !!needFocus); @@ -1002,6 +1048,13 @@ const Select = { } }, + clearComboboxTime() { + if (this.comboboxTimer) { + clearTimeout(this.comboboxTimer); + this.comboboxTimer = null; + } + }, + updateFocusClassName() { const { rootRef, prefixCls } = this; // avoid setState and its side effect @@ -1130,30 +1183,18 @@ const Select = { options.push(menuItem); menuItems.push(menuItem); }); - if (inputValue) { - const notFindInputItem = menuItems.every(option => { - // this.filterOption return true has two meaning, - // 1, some one exists after filtering - // 2, filterOption is set to false - // condition 2 does not mean the option has same value with inputValue - const filterFn = () => getValuePropValue(option) === inputValue; - if (filterOption !== false) { - return !this._filterOption(inputValue, option, filterFn); - } - return !filterFn(); - }); - if (notFindInputItem) { - const p = { - attrs: UNSELECTABLE_ATTRIBUTE, - key: inputValue, - props: { - value: inputValue, - role: 'option', - }, - style: UNSELECTABLE_STYLE, - }; - options.unshift({inputValue}); - } + // ref: https://github.com/ant-design/ant-design/issues/14090 + if (inputValue && menuItems.every(option => getValuePropValue(option) !== inputValue)) { + const p = { + attrs: UNSELECTABLE_ATTRIBUTE, + key: inputValue, + props: { + value: inputValue, + role: 'option', + }, + style: UNSELECTABLE_STYLE, + }; + options.unshift({inputValue}); } } diff --git a/components/vc-select/SelectTrigger.jsx b/components/vc-select/SelectTrigger.jsx index 7dfe3d2d9..ba8e906a3 100644 --- a/components/vc-select/SelectTrigger.jsx +++ b/components/vc-select/SelectTrigger.jsx @@ -1,4 +1,5 @@ import classnames from 'classnames'; +import raf from 'raf'; import Trigger from '../vc-trigger'; import PropTypes from '../_util/vue-types'; import DropdownMenu from './DropdownMenu'; @@ -65,6 +66,7 @@ export default { }; }, created() { + this.rafInstance = null; this.saveDropdownMenuRef = saveRef(this, 'dropdownMenuRef'); this.saveTriggerRef = saveRef(this, 'triggerRef'); }, @@ -80,14 +82,24 @@ export default { this.setDropdownWidth(); }); }, + beforeDestroy() { + this.cancelRafInstance(); + }, methods: { setDropdownWidth() { - const width = this.$el.offsetWidth; - if (width !== this.dropdownWidth) { - this.setState({ dropdownWidth: width }); + this.cancelRafInstance(); + this.rafInstance = raf(() => { + const width = this.$el.offsetWidth; + if (width !== this.dropdownWidth) { + this.setState({ dropdownWidth: width }); + } + }); + }, + cancelRafInstance() { + if (this.rafInstance) { + raf.cancel(this.rafInstance); } }, - getInnerMenu() { return this.dropdownMenuRef && this.dropdownMenuRef.$refs.menuRef; }, diff --git a/components/vc-select/index.js b/components/vc-select/index.js index 0fd5fb7b1..51b6f86a7 100644 --- a/components/vc-select/index.js +++ b/components/vc-select/index.js @@ -1,4 +1,4 @@ -// based on vc-select 8.9.0 +// based on vc-select 9.2.2 import ProxySelect, { Select } from './Select'; import Option from './Option'; import { SelectPropTypes } from './PropTypes';