Skip to content

Commit cc98b33

Browse files
authored
feat: refactor allowClear (#965)
* feat: refactor allowClear * feat: refactor allowClear * docs: update docs * feat: optimize code * feat: test case
1 parent 7d140b7 commit cc98b33

File tree

6 files changed

+162
-63
lines changed

6 files changed

+162
-63
lines changed

README.md

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -76,34 +76,34 @@ export default () => (
7676
| animation | dropdown animation name. only support slide-up now | String | '' |
7777
| transitionName | dropdown css animation name | String | '' |
7878
| choiceTransitionName | css animation name for selected items at multiple mode | String | '' |
79-
| dropdownMatchSelectWidth | whether dropdown's width is same with select | bool | true |
79+
| dropdownMatchSelectWidth | whether dropdown's width is same with select | boolean | true |
8080
| dropdownClassName | additional className applied to dropdown | String | - |
8181
| dropdownStyle | additional style applied to dropdown | React.CSSProperties | {} |
8282
| dropdownAlign | additional align applied to dropdown | [AlignType](https://github.com/react-component/trigger/blob/728d7e92394aa4b3214650f743fc47e1382dfa68/src/interface.ts#L25-L80) | {} |
8383
| dropdownMenuStyle | additional style applied to dropdown menu | Object | React.CSSProperties |
8484
| notFoundContent | specify content to show when no result matches. | ReactNode | 'Not Found' |
8585
| tokenSeparators | separator used to tokenize on tag/multiple mode | string[]? | |
86-
| open | control select open | bool | |
87-
| defaultOpen | control select default open | bool | |
86+
| open | control select open | boolean | |
87+
| defaultOpen | control select default open | boolean | |
8888
| placeholder | select placeholder | React Node | |
89-
| showSearch | whether show search input in single mode | bool | true |
90-
| allowClear | whether allowClear | bool | false |
91-
| tags | when tagging is enabled the user can select from pre-existing options or create a new tag by picking the first choice, which is what the user has typed into the search box so far. | bool | false |
89+
| showSearch | whether show search input in single mode | boolean | true |
90+
| allowClear | whether allowClear | boolean | { clearIcon?: ReactNode } | false |
91+
| tags | when tagging is enabled the user can select from pre-existing options or create a new tag by picking the first choice, which is what the user has typed into the search box so far. | boolean | false |
9292
| tagRender | render custom tags. | (props: CustomTagProps) => ReactNode | - |
9393
| maxTagTextLength | max tag text length to show | number | - |
9494
| maxTagCount | max tag count to show | number | - |
9595
| maxTagPlaceholder | placeholder for omitted values | ReactNode/function(omittedValues) | - |
96-
| combobox | enable combobox mode(can not set multiple at the same time) | bool | false |
97-
| multiple | whether multiple select | bool | false |
98-
| disabled | whether disabled select | bool | false |
99-
| filterOption | whether filter options by input value. default filter by option's optionFilterProp prop's value | bool | true/Function(inputValue:string, option:Option) |
96+
| combobox | enable combobox mode(can not set multiple at the same time) | boolean | false |
97+
| multiple | whether multiple select | boolean | false |
98+
| disabled | whether disabled select | boolean | false |
99+
| filterOption | whether filter options by input value. default filter by option's optionFilterProp prop's value | boolean | true/Function(inputValue:string, option:Option) |
100100
| optionFilterProp | which prop value of option will be used for filter if filterOption is true | String | 'value' |
101101
| filterSort | Sort function for search options sorting, see [Array.sort](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort)'s compareFunction. | Function(optionA:Option, optionB: Option) | - |
102102
| optionLabelProp | render option value or option children as content of select | String: 'value'/'children' | 'value' |
103103
| defaultValue | initial selected option(s) | String \| String[] | - |
104104
| value | current selected option(s) | String \| String[] \| {key:String, label:React.Node} \| {key:String, label:React.Node}[] | - |
105-
| labelInValue | whether to embed label in value, see above value type. Not support `combobox` mode | Bool | false |
106-
| backfill | whether backfill select option to search input (Only works in single and combobox mode) | Bool | false |
105+
| labelInValue | whether to embed label in value, see above value type. Not support `combobox` mode | boolean | false |
106+
| backfill | whether backfill select option to search input (Only works in single and combobox mode) | boolean | false |
107107
| onChange | called when select an option or input value change(combobox) | function(value, option:Option \| Option[]) | - |
108108
| onSearch | called when input changed | function | - |
109109
| onBlur | called when blur | function | - |
@@ -112,19 +112,19 @@ export default () => (
112112
| onSelect | called when a option is selected. param is option's value and option instance | Function(value, option:Option) | - |
113113
| onDeselect | called when a option is deselected. param is option's value. only called for multiple or tags | Function(value, option:Option) | - |
114114
| onInputKeyDown | called when key down on input | Function(event) | - |
115-
| defaultActiveFirstOption | whether active first option by default | bool | true |
115+
| defaultActiveFirstOption | whether active first option by default | boolean | true |
116116
| getPopupContainer | container which popup select menu rendered into | function(trigger:Node):Node | function(){return document.body;} |
117117
| getInputElement | customize input element | function(): Element | - |
118118
| showAction | actions trigger the dropdown to show | String[]? | - |
119-
| autoFocus | focus select after mount | Bool | - |
119+
| autoFocus | focus select after mount | boolean | - |
120120
| autoClearSearchValue | auto clear search input value when multiple select is selected/deselected | boolean | true |
121121
| suffixIcon | specify the select arrow icon | ReactNode | - |
122122
| clearIcon | specify the clear icon | ReactNode | - |
123123
| removeIcon | specify the remove icon | ReactNode | - |
124124
| menuItemSelectedIcon | specify the item selected icon | ReactNode \| (props: MenuItemProps) => ReactNode | - |
125125
| dropdownRender | render custom dropdown menu | (menu: React.Node, props: MenuProps) => ReactNode | - |
126-
| loading | show loading icon in arrow | Boolean | false |
127-
| virtual | Disable virtual scroll | Boolean | true |
126+
| loading | show loading icon in arrow | boolean | false |
127+
| virtual | Disable virtual scroll | boolean | true |
128128
| direction | direction of dropdown | 'ltr' \| 'rtl' | 'ltr' |
129129

130130
### Methods
@@ -139,7 +139,7 @@ export default () => (
139139
| name | description | type | default |
140140
| --- | --- | --- | --- |
141141
| className | additional class to option | String | '' |
142-
| disabled | no effect for click or keydown for this item | bool | false |
142+
| disabled | no effect for click or keydown for this item | boolean | false |
143143
| key | if react want you to set key, then key is same as value, you can omit value | String/number | - |
144144
| value | default filter by this attribute. if react want you to set key, then key is same as value, you can omit value | String/number | - |
145145
| title | if you are not satisfied with auto-generated `title` which is show while hovering on selected value, you can customize it with this property | String | - |

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"@types/jest": "^26.0.24",
6060
"@types/react": "^17.0.15",
6161
"@types/react-dom": "^17.0.3",
62+
"babel-jest": "^29.6.1",
6263
"cross-env": "^7.0.0",
6364
"dumi": "^1.1.32",
6465
"enzyme": "^3.3.0",

src/BaseSelect.tsx

Lines changed: 36 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ import type { RefTriggerProps } from './SelectTrigger';
1717
import SelectTrigger from './SelectTrigger';
1818
import TransBtn from './TransBtn';
1919
import { getSeparatedContent } from './utils/valueUtil';
20+
import type { DisplayInfoType, DisplayValueType, Mode, Placement, RenderDOMFunc, RenderNode, RawValueType } from './interface';
21+
import { useAllowClear } from './hooks/useAllowClear';
22+
import { warning } from 'rc-util';
23+
24+
export type { DisplayInfoType, DisplayValueType, Mode, Placement, RenderDOMFunc, RenderNode, RawValueType };
2025

2126
const DEFAULT_OMIT_PROPS = [
2227
'value',
@@ -32,19 +37,6 @@ const DEFAULT_OMIT_PROPS = [
3237
'onPopupScroll',
3338
'tabIndex',
3439
] as const;
35-
36-
export type RenderNode = React.ReactNode | ((props: any) => React.ReactNode);
37-
38-
export type RenderDOMFunc = (props: any) => HTMLElement;
39-
40-
export type Mode = 'multiple' | 'tags' | 'combobox';
41-
42-
export type Placement = 'bottomLeft' | 'bottomRight' | 'topLeft' | 'topRight';
43-
44-
export type RawValueType = string | number;
45-
46-
export type DisplayInfoType = 'add' | 'remove' | 'clear';
47-
4840
export interface RefOptionListProps {
4941
onKeyDown: React.KeyboardEventHandler;
5042
onKeyUp: React.KeyboardEventHandler;
@@ -59,14 +51,6 @@ export type CustomTagProps = {
5951
closable: boolean;
6052
};
6153

62-
export interface DisplayValueType {
63-
key?: React.Key;
64-
value?: RawValueType;
65-
label?: React.ReactNode;
66-
title?: string | number;
67-
disabled?: boolean;
68-
}
69-
7054
export interface BaseSelectRef {
7155
focus: () => void;
7256
blur: () => void;
@@ -104,10 +88,10 @@ export interface BaseSelectPrivateProps {
10488
searchValue: string,
10589
info: {
10690
source:
107-
| 'typing' //User typing
108-
| 'effect' // Code logic trigger
109-
| 'submit' // tag mode only
110-
| 'blur'; // Not trigger event
91+
| 'typing' //User typing
92+
| 'effect' // Code logic trigger
93+
| 'submit' // tag mode only
94+
| 'blur'; // Not trigger event
11195
},
11296
) => void;
11397
/** Trigger when search text match the `tokenSeparators`. Will provide split content */
@@ -168,9 +152,12 @@ export interface BaseSelectProps extends BaseSelectPrivateProps, React.AriaAttri
168152
tokenSeparators?: string[];
169153

170154
// >>> Icons
171-
allowClear?: boolean;
155+
allowClear?: boolean | { clearIcon?: RenderNode };
172156
suffixIcon?: RenderNode;
173-
/** Clear all icon */
157+
/**
158+
* Clear all icon
159+
* @deprecated Please use `allowClear` instead
160+
**/
174161
clearIcon?: RenderNode;
175162
/** Selector remove icon */
176163
removeIcon?: RenderNode;
@@ -697,7 +684,12 @@ const BaseSelect = React.forwardRef((props: BaseSelectProps, ref: React.Ref<Base
697684
}
698685

699686
// ============================= Clear ==============================
700-
let clearNode: React.ReactNode;
687+
if (process.env.NODE_ENV !== 'production') {
688+
warning(
689+
!props.clearIcon,
690+
'`clearIcon` will be removed in future. Please use `allowClear` instead.',
691+
);
692+
}
701693
const onClearMouseDown: React.MouseEventHandler<HTMLSpanElement> = () => {
702694
onClear?.();
703695

@@ -710,22 +702,21 @@ const BaseSelect = React.forwardRef((props: BaseSelectProps, ref: React.Ref<Base
710702
onInternalSearch('', false, false);
711703
};
712704

713-
if (
714-
!disabled &&
715-
allowClear &&
716-
(displayValues.length || mergedSearchValue) &&
717-
!(mode === 'combobox' && mergedSearchValue === '')
718-
) {
719-
clearNode = (
720-
<TransBtn
721-
className={`${prefixCls}-clear`}
722-
onMouseDown={onClearMouseDown}
723-
customizeIcon={clearIcon}
724-
>
725-
×
726-
</TransBtn>
727-
);
728-
}
705+
const {
706+
allowClear: mergedAllowClear,
707+
clearIcon: clearNode
708+
} = useAllowClear(
709+
prefixCls,
710+
onClearMouseDown,
711+
displayValues,
712+
allowClear,
713+
clearIcon,
714+
disabled,
715+
716+
mergedSearchValue,
717+
mode,
718+
);
719+
729720

730721
// =========================== OptionList ===========================
731722
const optionList = <OptionList ref={listRef} />;
@@ -839,7 +830,7 @@ const BaseSelect = React.forwardRef((props: BaseSelectProps, ref: React.Ref<Base
839830
)}
840831
{selectorNode}
841832
{arrowNode}
842-
{clearNode}
833+
{mergedAllowClear && clearNode}
843834
</div>
844835
);
845836
}

src/hooks/useAllowClear.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import TransBtn from '../TransBtn';
2+
import type { DisplayValueType, Mode } from '../interface';
3+
import type { ReactNode } from 'react';
4+
import React from 'react';
5+
6+
export function useAllowClear(
7+
prefixCls,
8+
onClearMouseDown,
9+
displayValues: DisplayValueType[],
10+
allowClear?: boolean | { clearIcon?: ReactNode },
11+
clearIcon?: ReactNode,
12+
disabled = false,
13+
mergedSearchValue?: string,
14+
mode?: Mode
15+
) {
16+
const mergedClearIcon = React.useMemo(() => {
17+
if (typeof allowClear === "object") {
18+
return allowClear.clearIcon;
19+
}
20+
if (!!clearIcon) return clearIcon;
21+
}, [allowClear, clearIcon]);
22+
23+
24+
const mergedAllowClear = React.useMemo(() => {
25+
if (
26+
!disabled &&
27+
!!allowClear &&
28+
(displayValues.length || mergedSearchValue) &&
29+
!(mode === 'combobox' && mergedSearchValue === '')
30+
) {
31+
return true;
32+
}
33+
return false;
34+
}, [allowClear, disabled, displayValues.length, mergedSearchValue, mode]);
35+
36+
return {
37+
allowClear: mergedAllowClear,
38+
clearIcon: (
39+
<TransBtn
40+
className={`${prefixCls}-clear`}
41+
onMouseDown={onClearMouseDown}
42+
customizeIcon={mergedClearIcon}
43+
>
44+
×
45+
</TransBtn>
46+
)
47+
};
48+
}

src/interface.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type * as React from 'react';
2-
import type { RawValueType } from './BaseSelect';
32

3+
export type RawValueType = string | number;
44
export interface FlattenOptionData<OptionType> {
55
label?: React.ReactNode;
66
data: OptionType;
@@ -9,3 +9,22 @@ export interface FlattenOptionData<OptionType> {
99
groupOption?: boolean;
1010
group?: boolean;
1111
}
12+
13+
export interface DisplayValueType {
14+
key?: React.Key;
15+
value?: RawValueType;
16+
label?: React.ReactNode;
17+
title?: string | number;
18+
disabled?: boolean;
19+
}
20+
21+
export type RenderNode = React.ReactNode | ((props: any) => React.ReactNode);
22+
23+
export type RenderDOMFunc = (props: any) => HTMLElement;
24+
25+
export type Mode = 'multiple' | 'tags' | 'combobox';
26+
27+
export type Placement = 'bottomLeft' | 'bottomRight' | 'topLeft' | 'topRight';
28+
29+
export type DisplayInfoType = 'add' | 'remove' | 'clear';
30+

tests/Select.test.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,46 @@ describe('Select.Basic', () => {
266266
</Select>,
267267
);
268268
expect(wrapper2.find('.rc-select-clear-icon').length).toBeFalsy();
269+
270+
const wrapper3 = mount(
271+
<Select allowClear={{ clearIcon: <div className='custom-clear-icon'>x</div> }} value="1">
272+
<Option value="1">1</Option>
273+
<Option value="2">2</Option>
274+
</Select>,
275+
);
276+
expect(wrapper3.find('.custom-clear-icon').length).toBeTruthy();
277+
expect(wrapper3.find('.custom-clear-icon').text()).toBe('x');
278+
279+
const wrapper4 = mount(
280+
<Select allowClear={{ clearIcon: <div className='custom-clear-icon'>x</div> }}>
281+
<Option value="1">1</Option>
282+
<Option value="2">2</Option>
283+
</Select>,
284+
);
285+
expect(wrapper4.find('.custom-clear-icon').length).toBeFalsy();
286+
287+
288+
resetWarned();
289+
const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
290+
const wrapper5 = mount(
291+
<Select allowClear clearIcon={<div className='custom-clear-icon'>x</div>} value="1">
292+
<Option value="1">1</Option>
293+
<Option value="2">2</Option>
294+
</Select>,
295+
);
296+
expect(wrapper5.find('.custom-clear-icon').length).toBeTruthy();
297+
expect(wrapper5.find('.custom-clear-icon').text()).toBe('x');
298+
expect(errSpy).toHaveBeenCalledWith(
299+
'Warning: `clearIcon` will be removed in future. Please use `allowClear` instead.'
300+
);
301+
302+
const wrapper6 = mount(
303+
<Select allowClear clearIcon={<div className='custom-clear-icon'>x</div>}>
304+
<Option value="1">1</Option>
305+
<Option value="2">2</Option>
306+
</Select>,
307+
);
308+
expect(wrapper6.find('.custom-clear-icon').length).toBeFalsy();
269309
});
270310

271311
it('should direction rtl', () => {

0 commit comments

Comments
 (0)