diff --git a/README.md b/README.md index 7a9c56f9..6a27c9ce 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,7 @@ export default () => ( | virtual | Disable virtual scroll | boolean | true | | direction | direction of dropdown | 'ltr' \| 'rtl' | 'ltr' | | optionRender | Custom rendering options | (oriOption: FlattenOptionData\ , info: { index: number }) => React.ReactNode | - | +| maxCount | The max number of items can be selected | number | - | ### Methods diff --git a/docs/demo/multiple-with-maxCount.md b/docs/demo/multiple-with-maxCount.md new file mode 100644 index 00000000..668c3f94 --- /dev/null +++ b/docs/demo/multiple-with-maxCount.md @@ -0,0 +1,8 @@ +--- +title: multiple-with-maxCount +nav: + title: Demo + path: /demo +--- + + diff --git a/docs/examples/mul-suggest.tsx b/docs/examples/mul-suggest.tsx index b9e1e946..d1802a6d 100644 --- a/docs/examples/mul-suggest.tsx +++ b/docs/examples/mul-suggest.tsx @@ -11,15 +11,15 @@ class Test extends React.Component { value: [], }; - onChange = value => { + onChange = (value) => { console.log('onChange ', value); this.setState({ value, }); }; - fetchData = value => { - fetch(value, data => { + fetchData = (value) => { + fetch(value, (data) => { this.setState({ data, }); @@ -28,7 +28,7 @@ class Test extends React.Component { render() { const { data, value } = this.state; - const options = data.map(d => ( + const options = data.map((d) => ( diff --git a/docs/examples/mul-tag-suggest.tsx b/docs/examples/mul-tag-suggest.tsx index b2ef2fe2..5eeceb99 100644 --- a/docs/examples/mul-tag-suggest.tsx +++ b/docs/examples/mul-tag-suggest.tsx @@ -11,19 +11,19 @@ class Test extends React.Component { value: [], }; - onChange = value => { + onChange = (value) => { console.log('onChange ', value); this.setState({ value, }); }; - onSelect = value => { + onSelect = (value) => { console.log('select ', value); }; - fetchData = value => { - fetch(value, data => { + fetchData = (value) => { + fetch(value, (data) => { this.setState({ data, }); @@ -32,7 +32,7 @@ class Test extends React.Component { render() { const { value, data } = this.state; - const options = data.map(d => ( + const options = data.map((d) => ( diff --git a/docs/examples/multiple-readonly.tsx b/docs/examples/multiple-readonly.tsx index 9ed2a34a..11f5e232 100644 --- a/docs/examples/multiple-readonly.tsx +++ b/docs/examples/multiple-readonly.tsx @@ -3,7 +3,8 @@ import React from 'react'; import Select, { Option } from 'rc-select'; import '../../assets/index.less'; -const children = []; +const children: React.ReactNode[] = []; + for (let i = 10; i < 36; i += 1) { // 11 => readonly selected item children.push( @@ -13,40 +14,35 @@ for (let i = 10; i < 36; i += 1) { ); } -class Test extends React.Component { - state = { - value: ['b11'], - }; +const Test: React.FC = () => { + const [value, setValue] = React.useState(['b11']); - onChange = value => { - console.log('onChange', value); - this.setState({ value }); + const onChange = (v: any) => { + console.log('onChange', v); + setValue(v); }; - render() { - const { value } = this.state; - return ( -
-

multiple readonly default selected item

-
- -
+ return ( +
+

multiple readonly default selected item

+
+
- ); - } -} +
+ ); +}; export default Test; /* eslint-enable */ diff --git a/docs/examples/multiple-with-maxCount.tsx b/docs/examples/multiple-with-maxCount.tsx new file mode 100644 index 00000000..af41bd23 --- /dev/null +++ b/docs/examples/multiple-with-maxCount.tsx @@ -0,0 +1,36 @@ +/* eslint-disable no-console */ +import React from 'react'; +import Select from 'rc-select'; +import '../../assets/index.less'; + +const Test: React.FC = () => { + const [value, setValue] = React.useState(['1']); + + const onChange = (v: any) => { + setValue(v); + }; + + return ( + <> +

Multiple with maxCount

+ +

@@ -102,33 +108,32 @@ class Test extends React.Component { placeholder="please select" onChange={this.onChange} onFocus={() => console.log('focus')} - onBlur={v => console.log('blur', v)} + onBlur={(v) => console.log('blur', v)} tokenSeparators={[' ', ',']} > {children}

-

multiple select with autoClearSearchValue = false

diff --git a/package.json b/package.json index 51efe477..f6ada0d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-select", - "version": "14.10.0", + "version": "14.11.0-0", "description": "React Select", "engines": { "node": ">=8.x" diff --git a/src/BaseSelect.tsx b/src/BaseSelect.tsx index e13f3f46..3fef85c8 100644 --- a/src/BaseSelect.tsx +++ b/src/BaseSelect.tsx @@ -209,7 +209,7 @@ export function isMultiple(mode: Mode) { return mode === 'tags' || mode === 'multiple'; } -const BaseSelect = React.forwardRef((props: BaseSelectProps, ref: React.Ref) => { +const BaseSelect = React.forwardRef((props, ref) => { const { id, prefixCls, @@ -807,14 +807,8 @@ const BaseSelect = React.forwardRef((props: BaseSelectProps, ref: React.Ref {mockFocused && !mergedOpen && ( {/* Merge into one string to make screen reader work as expect */} {`${displayValues diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 6e880252..8c8b8329 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -45,6 +45,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, r onPopupScroll, } = useBaseProps(); const { + maxCount, flattenOptions, onActiveValue, defaultActiveFirstOption, @@ -70,6 +71,11 @@ const OptionList: React.ForwardRefRenderFunction = (_, r // =========================== List =========================== const listRef = React.useRef(null); + const overMaxCount = React.useMemo( + () => multiple && typeof maxCount !== 'undefined' && rawValues.size >= maxCount, + [multiple, maxCount, rawValues.size], + ); + const onListMouseDown: React.MouseEventHandler = (event) => { event.preventDefault(); }; @@ -87,8 +93,9 @@ const OptionList: React.ForwardRefRenderFunction = (_, r for (let i = 0; i < len; i += 1) { const current = (index + i * offset + len) % len; - const { group, data } = memoFlattenOptions[current]; - if (!group && !data.disabled) { + const { group, data } = memoFlattenOptions[current] || {}; + + if (!group && !data?.disabled && !overMaxCount) { return current; } } @@ -197,7 +204,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, r case KeyCode.ENTER: { // value const item = memoFlattenOptions[activeIndex]; - if (item && !item.data.disabled) { + if (item && !item?.data?.disabled && !overMaxCount) { onSelectValue(item.value); } else { onSelectValue(undefined); @@ -255,8 +262,9 @@ const OptionList: React.ForwardRefRenderFunction = (_, r const renderItem = (index: number) => { const item = memoFlattenOptions[index]; - if (!item) return null; - + if (!item) { + return null; + } const itemData = item.data || {}; const { value } = itemData; const { group } = item; @@ -326,11 +334,13 @@ const OptionList: React.ForwardRefRenderFunction = (_, r // Option const selected = isSelected(value); + const mergedDisabled = disabled || (!selected && overMaxCount); + const optionPrefixCls = `${itemPrefixCls}-option`; const optionClassName = classNames(itemPrefixCls, optionPrefixCls, className, { [`${optionPrefixCls}-grouped`]: groupOption, - [`${optionPrefixCls}-active`]: activeIndex === itemIndex && !disabled, - [`${optionPrefixCls}-disabled`]: disabled, + [`${optionPrefixCls}-active`]: activeIndex === itemIndex && !mergedDisabled, + [`${optionPrefixCls}-disabled`]: mergedDisabled, [`${optionPrefixCls}-selected`]: selected, }); @@ -355,13 +365,13 @@ const OptionList: React.ForwardRefRenderFunction = (_, r className={optionClassName} title={optionTitle} onMouseMove={() => { - if (activeIndex === itemIndex || disabled) { + if (activeIndex === itemIndex || mergedDisabled) { return; } setActive(itemIndex); }} onClick={() => { - if (!disabled) { + if (!mergedDisabled) { onSelectValue(value); } }} @@ -379,7 +389,7 @@ const OptionList: React.ForwardRefRenderFunction = (_, r customizeIcon={menuItemSelectedIcon} customizeIconProps={{ value, - disabled, + disabled: mergedDisabled, isSelected: selected, }} > diff --git a/src/Select.tsx b/src/Select.tsx index 25c1d147..ee91bc50 100644 --- a/src/Select.tsx +++ b/src/Select.tsx @@ -45,6 +45,7 @@ import OptGroup from './OptGroup'; import Option from './Option'; import OptionList from './OptionList'; import SelectContext from './SelectContext'; +import type { SelectContextProps } from './SelectContext'; import useCache from './hooks/useCache'; import useFilterOptions from './hooks/useFilterOptions'; import useId from './hooks/useId'; @@ -156,6 +157,7 @@ export interface SelectProps void; } @@ -163,8 +165,8 @@ function isRawValue(value: DraftValueType): value is RawValueType { return !value || typeof value !== 'object'; } -const Select = React.forwardRef( - (props: SelectProps, ref: React.Ref) => { +const Select = React.forwardRef>( + (props, ref) => { const { id, mode, @@ -203,6 +205,7 @@ const Select = React.forwardRef( defaultValue, labelInValue, onChange, + maxCount, ...restProps } = props; @@ -311,7 +314,8 @@ const Select = React.forwardRef( // Merged value with LabelValueType const rawLabeledValues = React.useMemo(() => { - const values = convert2LabelValues(internalValue); + const newInternalValue = multiple && internalValue === null ? [] : internalValue; + const values = convert2LabelValues(newInternalValue); // combobox no need save value when it's no value (exclude value equal 0) if (mode === 'combobox' && isComboNoValue(values[0]?.value)) { @@ -319,7 +323,7 @@ const Select = React.forwardRef( } return values; - }, [internalValue, convert2LabelValues, mode]); + }, [internalValue, convert2LabelValues, mode, multiple]); // Fill label with cache to avoid option remove const [mergedValues, getMixedOption] = useCache(rawLabeledValues, valueOptions); @@ -595,7 +599,7 @@ const Select = React.forwardRef( }; // ========================== Context =========================== - const selectContext = React.useMemo(() => { + const selectContext = React.useMemo(() => { const realVirtual = virtual !== false && dropdownMatchSelectWidth !== false; return { ...parsedOptions, @@ -611,9 +615,11 @@ const Select = React.forwardRef( listHeight, listItemHeight, childrenAsData, + maxCount, optionRender, }; }, [ + maxCount, parsedOptions, displayOptions, onActiveValue, @@ -624,6 +630,7 @@ const Select = React.forwardRef( mergedFieldNames, virtual, dropdownMatchSelectWidth, + direction, listHeight, listItemHeight, childrenAsData, diff --git a/src/SelectContext.ts b/src/SelectContext.ts index cd7d5f72..e124425f 100644 --- a/src/SelectContext.ts +++ b/src/SelectContext.ts @@ -25,6 +25,7 @@ export interface SelectContextProps { listHeight?: number; listItemHeight?: number; childrenAsData?: boolean; + maxCount?: number; } const SelectContext = React.createContext(null); diff --git a/src/Selector/Input.tsx b/src/Selector/Input.tsx index 2af2ee76..706ec460 100644 --- a/src/Selector/Input.tsx +++ b/src/Selector/Input.tsx @@ -33,8 +33,8 @@ interface InputProps { >; } -const Input: React.ForwardRefRenderFunction = ( - { +const Input: React.ForwardRefRenderFunction = (props, ref) => { + const { prefixCls, id, inputElement, @@ -54,9 +54,8 @@ const Input: React.ForwardRefRenderFunction = ( onCompositionEnd, open, attrs, - }, - ref, -) => { + } = props; + let inputNode: React.ComponentElement = inputElement || ; const { ref: originRef, props: originProps } = inputNode; diff --git a/src/Selector/MultipleSelector.tsx b/src/Selector/MultipleSelector.tsx index 6bfce7b9..8ad89025 100644 --- a/src/Selector/MultipleSelector.tsx +++ b/src/Selector/MultipleSelector.tsx @@ -37,6 +37,7 @@ const onPreventMouseDown = (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); }; + const SelectSelector: React.FC = (props) => { const { id, @@ -96,61 +97,52 @@ const SelectSelector: React.FC = (props) => { // ===================== Render ====================== // >>> Render Selector Node. Includes Item & Rest - function defaultRenderSelector( + const defaultRenderSelector = ( item: DisplayValueType, content: React.ReactNode, itemDisabled: boolean, closable?: boolean, onClose?: React.MouseEventHandler, - ) { - return ( - - {content} - {closable && ( - - × - - )} - - ); - } + ) => ( + + {content} + {closable && ( + + × + + )} + + ); - function customizeRenderSelector( + const customizeRenderSelector = ( value: RawValueType, content: React.ReactNode, itemDisabled: boolean, closable: boolean, onClose: React.MouseEventHandler, - ) { + ) => { const onMouseDown = (e: React.MouseEvent) => { onPreventMouseDown(e); onToggleOpen(!open); }; - return ( - {tagRender({ - label: content, - value, - disabled: itemDisabled, - closable, - onClose, - })} + {tagRender({ label: content, value, disabled: itemDisabled, closable, onClose })} ); - } + }; - function renderItem(valueItem: DisplayValueType) { + const renderItem = (valueItem: DisplayValueType) => { const { disabled: itemDisabled, label, value } = valueItem; const closable = !disabled && !itemDisabled; @@ -159,7 +151,6 @@ const SelectSelector: React.FC = (props) => { if (typeof maxTagTextLength === 'number') { if (typeof label === 'string' || typeof label === 'number') { const strLabel = String(displayLabel); - if (strLabel.length > maxTagTextLength) { displayLabel = `${strLabel.slice(0, maxTagTextLength)}...`; } @@ -167,23 +158,25 @@ const SelectSelector: React.FC = (props) => { } const onClose = (event?: React.MouseEvent) => { - if (event) event.stopPropagation(); + if (event) { + event.stopPropagation(); + } onRemove(valueItem); }; return typeof tagRender === 'function' ? customizeRenderSelector(value, displayLabel, itemDisabled, closable, onClose) : defaultRenderSelector(valueItem, displayLabel, itemDisabled, closable, onClose); - } + }; - function renderRest(omittedValues: DisplayValueType[]) { + const renderRest = (omittedValues: DisplayValueType[]) => { const content = typeof maxTagPlaceholder === 'function' ? maxTagPlaceholder(omittedValues) : maxTagPlaceholder; return defaultRenderSelector({ title: content }, content, false); - } + }; // >>> Input Node const inputNode = ( diff --git a/src/Selector/SingleSelector.tsx b/src/Selector/SingleSelector.tsx index 98d06f16..7fd7f69b 100644 --- a/src/Selector/SingleSelector.tsx +++ b/src/Selector/SingleSelector.tsx @@ -62,19 +62,19 @@ const SingleSelector: React.FC = (props) => { // Get title of selection item const selectionTitle = title === undefined ? getTitle(item) : title; - const renderPlaceholder = () => { + const placeholderNode = React.useMemo(() => { if (item) { return null; } - const hiddenStyle = hasTextInput - ? ({ visibility: 'hidden' } as React.CSSProperties) - : undefined; return ( - + {placeholder} ); - }; + }, [item, hasTextInput, placeholder, prefixCls]); return ( <> @@ -114,14 +114,13 @@ const SingleSelector: React.FC = (props) => { // 当 Select 已经选中选项时,还需 selection 隐藏但留在原地占位 // https://github.com/ant-design/ant-design/issues/27688 // https://github.com/ant-design/ant-design/issues/41530 - style={hasTextInput ? ({ visibility: 'hidden' } as React.CSSProperties) : undefined} + style={hasTextInput ? { visibility: 'hidden' } : undefined} > {item.label} ) : null} - {/* Display placeholder */} - {renderPlaceholder()} + {placeholderNode} ); }; diff --git a/src/hooks/useOptions.ts b/src/hooks/useOptions.ts index f3ccb67d..ed4d4c13 100644 --- a/src/hooks/useOptions.ts +++ b/src/hooks/useOptions.ts @@ -6,13 +6,13 @@ import { convertChildrenToData } from '../utils/legacyUtil'; * Parse `children` to `options` if `options` is not provided. * Then flatten the `options`. */ -export default function useOptions( +const useOptions = ( options: OptionType[], children: React.ReactNode, fieldNames: FieldNames, optionFilterProp: string, optionLabelProp: string, -) { +) => { return React.useMemo(() => { let mergedOptions = options; const childrenAsData = !options; @@ -24,13 +24,17 @@ export default function useOptions( const valueOptions = new Map(); const labelOptions = new Map(); - const setLabelOptions = (labelOptionsMap, option, key) => { + const setLabelOptions = ( + labelOptionsMap: Map, + option: OptionType, + key: string | number, + ) => { if (key && typeof key === 'string') { labelOptionsMap.set(option[key], option); } }; - function dig(optionList: OptionType[], isChildren = false) { + const dig = (optionList: OptionType[], isChildren = false) => { // for loop to speed up collection speed for (let i = 0; i < optionList.length; i += 1) { const option = optionList[i]; @@ -44,7 +48,8 @@ export default function useOptions( dig(option[fieldNames.options], true); } } - } + }; + dig(mergedOptions); return { @@ -53,4 +58,6 @@ export default function useOptions( labelOptions, }; }, [options, children, fieldNames, optionFilterProp, optionLabelProp]); -} +}; + +export default useOptions; diff --git a/tests/Multiple.test.tsx b/tests/Multiple.test.tsx index a5debc88..6e6a051a 100644 --- a/tests/Multiple.test.tsx +++ b/tests/Multiple.test.tsx @@ -546,6 +546,24 @@ describe('Select.Multiple', () => { expect(wrapper.find('Selector').props().values.length).toEqual(0); }); + it('display correctly when value is undefined or null', () => { + const wrapper1 = mount( + , + ); + const wrapper2 = mount( + , + ); + + expect(wrapper1.find('.rc-select-selection-item').length).toBe(0); + expect(wrapper2.find('.rc-select-selection-item').length).toBe(0); + }); + describe('optionLabelProp', () => { it('basic', () => { const wrapper = mount( @@ -622,15 +640,10 @@ describe('Select.Multiple', () => { }); }); - describe("autoClearSearchValue", () => { + describe('autoClearSearchValue', () => { it('search value should not show when autoClearSearchValue is undefined', () => { const wrapper = mount( - , ); expect(wrapper.find('input').props().value).toBe(''); }); @@ -648,12 +661,12 @@ describe('Select.Multiple', () => { }); it('search value should no clear when autoClearSearchValue is false', () => { const wrapper = mount( - , ); toggleOpen(wrapper); @@ -662,12 +675,7 @@ describe('Select.Multiple', () => { }); it('search value should clear when autoClearSearchValue is true', () => { const wrapper = mount( - , ); toggleOpen(wrapper); toggleOpen(wrapper); diff --git a/tests/Select.test.tsx b/tests/Select.test.tsx index 3ff82ec7..f856f4b0 100644 --- a/tests/Select.test.tsx +++ b/tests/Select.test.tsx @@ -653,7 +653,7 @@ describe('Select.Basic', () => { }); describe('click input will trigger focus', () => { - let handleFocus; + let handleFocus: jest.Mock; let wrapper; beforeEach(() => { jest.useFakeTimers(); @@ -690,15 +690,15 @@ describe('Select.Basic', () => { }); it('focus input when placeholder is clicked', () => { - const wrapper = mount( + const selectWrapper = mount( , ); - const inputSpy = jest.spyOn(wrapper.find('input').instance(), 'focus' as any); - wrapper.find('.rc-select-selection-placeholder').simulate('mousedown'); - wrapper.find('.rc-select-selection-placeholder').simulate('click'); + const inputSpy = jest.spyOn(selectWrapper.find('input').instance(), 'focus' as any); + selectWrapper.find('.rc-select-selection-placeholder').simulate('mousedown'); + selectWrapper.find('.rc-select-selection-placeholder').simulate('click'); expect(inputSpy).toHaveBeenCalled(); }); }); @@ -1499,7 +1499,7 @@ describe('Select.Basic', () => { ); expect(menuItemSelectedIcon).toHaveBeenCalledWith({ value: '1', - disabled: undefined, + disabled: false, isSelected: true, }); @@ -2105,7 +2105,7 @@ describe('Select.Basic', () => { , + ); + const element = container.querySelectorAll( + 'div.rc-virtual-list-holder-inner .rc-select-item', + ); + expect(element[0]).not.toHaveClass('rc-select-item-option-disabled'); + expect(element[1]).toHaveClass('rc-select-item-option-disabled'); + }); });