Skip to content

Commit e5ed351

Browse files
authored
feat: unify allowClear configuration (#1257)
1 parent 59dd34a commit e5ed351

7 files changed

Lines changed: 127 additions & 19 deletions

File tree

‎README.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,3 +167,20 @@ The release flow is handled by `@rc-component/np` through the `rc-np` command af
167167
## License
168168

169169
@rc-component/select is released under the [MIT](./LICENSE) license.
170+
171+
### Clear configuration
172+
173+
`allowClear` accepts a boolean or an `AllowClearConfig` object from `@rc-component/util`:
174+
175+
```ts
176+
interface AllowClearConfig {
177+
clearIcon?: React.ReactNode;
178+
disabled?: boolean;
179+
tabIndex?: number;
180+
label?: string;
181+
}
182+
```
183+
184+
All fields are optional. `disabled: true` behaves like `allowClear={false}`: the clear button and space reserved solely for it are removed; the input remains editable. Existing suffixes and other content are preserved. This changes the previous hidden-button spacing behavior where applicable.
185+
186+
`tabIndex` affects only the clear button; `-1` removes it from Tab navigation while retaining mouse activation. `label` supplies its accessible name through `aria-label`. Defaults, clear values, callbacks, and component state restrictions are unchanged.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
"dependencies": {
5151
"@rc-component/overflow": "^1.0.0",
5252
"@rc-component/trigger": "^3.0.0",
53-
"@rc-component/util": "^1.13.0",
53+
"@rc-component/util": "^1.14.0",
5454
"@rc-component/virtual-list": "^1.2.0",
5555
"clsx": "^2.1.1"
5656
},

‎src/BaseSelect/index.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { AlignType, BuildInPlacements } from '@rc-component/trigger';
22
import { clsx } from 'clsx';
3-
import { getDOM, isNonNullable, useEvent } from '@rc-component/util';
3+
import { type AllowClearConfig, getDOM, isNonNullable, useEvent } from '@rc-component/util';
44
import type { ScrollConfig, ScrollTo } from '@rc-component/virtual-list';
55
import * as React from 'react';
66
import { useAllowClear } from '../hooks/useAllowClear';
@@ -185,7 +185,7 @@ export interface BaseSelectProps
185185
tokenSeparators?: string[] | ((input: string) => string[]);
186186

187187
// >>> Icons
188-
allowClear?: boolean | { clearIcon?: React.ReactNode; label?: string };
188+
allowClear?: boolean | AllowClearConfig;
189189
prefix?: React.ReactNode;
190190
/** @deprecated Please use `suffix` instead. */
191191
suffixIcon?: RenderNode;
@@ -725,6 +725,7 @@ const BaseSelect = React.forwardRef<BaseSelectRef, BaseSelectProps>((props, ref)
725725
allowClear: mergedAllowClear,
726726
clearIcon: clearNode,
727727
label: clearLabel,
728+
tabIndex: clearTabIndex,
728729
} = useAllowClear(
729730
prefixCls,
730731
displayValues,
@@ -768,6 +769,7 @@ const BaseSelect = React.forwardRef<BaseSelectRef, BaseSelectProps>((props, ref)
768769
suffix={mergedSuffixIcon}
769770
clearIcon={clearNode}
770771
clearLabel={clearLabel}
772+
clearTabIndex={clearTabIndex}
771773
// Type or mode
772774
multiple={multiple}
773775
mode={mode}

‎src/SelectInput/index.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export interface SelectInputProps extends Omit<React.HTMLAttributes<HTMLDivEleme
2929
suffix?: React.ReactNode;
3030
clearIcon?: React.ReactNode;
3131
clearLabel?: string;
32+
clearTabIndex?: number;
3233
removeIcon?: RenderNode;
3334
multiple?: boolean;
3435
displayValues: DisplayValueType[];
@@ -89,6 +90,7 @@ export default React.forwardRef<SelectInputRef, SelectInputProps>(function Selec
8990
suffix,
9091
clearIcon,
9192
clearLabel,
93+
clearTabIndex,
9294
children,
9395

9496
// Data
@@ -310,6 +312,7 @@ export default React.forwardRef<SelectInputRef, SelectInputProps>(function Selec
310312
<button
311313
type="button"
312314
aria-label={clearLabel}
315+
tabIndex={clearTabIndex}
313316
className={clsx(`${prefixCls}-clear`, classNames?.clear)}
314317
style={styles?.clear}
315318
onMouseDown={(e) => {

‎src/hooks/useAllowClear.tsx‎

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import type { DisplayValueType, Mode } from '../interface';
22
import type React from 'react';
3-
import { isReactRenderable } from '@rc-component/util';
3+
import {
4+
type AllowClearConfig,
5+
isReactRenderable,
6+
useAllowClear as useClearConfig,
7+
} from '@rc-component/util';
48
import { useMemo } from 'react';
59

6-
export interface AllowClearConfig {
10+
interface ClearInfo extends AllowClearConfig {
711
allowClear: boolean;
812
clearIcon: React.ReactNode;
913
label: string;
@@ -12,31 +16,23 @@ export interface AllowClearConfig {
1216
export const useAllowClear = (
1317
prefixCls: string,
1418
displayValues: DisplayValueType[],
15-
allowClear?: boolean | { clearIcon?: React.ReactNode; label?: string },
19+
allowClear?: boolean | AllowClearConfig,
1620
clearIcon?: React.ReactNode,
1721
disabled: boolean = false,
1822
mergedSearchValue?: string,
1923
mode?: Mode,
20-
): AllowClearConfig => {
21-
// Convert boolean to object first
22-
const allowClearConfig = useMemo<Partial<AllowClearConfig>>(() => {
23-
if (typeof allowClear === 'boolean') {
24-
return { allowClear };
25-
}
26-
if (allowClear && typeof allowClear === 'object') {
27-
return allowClear;
28-
}
29-
return { allowClear: false };
30-
}, [allowClear]);
24+
): ClearInfo => {
25+
const allowClearConfig = useClearConfig(allowClear);
3126

3227
return useMemo(() => {
3328
const mergedAllowClear =
3429
!disabled &&
35-
allowClearConfig.allowClear !== false &&
30+
!allowClearConfig.disabled &&
3631
(displayValues.length || mergedSearchValue) &&
3732
!(mode === 'combobox' && mergedSearchValue === '');
3833

3934
return {
35+
...allowClearConfig,
4036
allowClear: mergedAllowClear,
4137
clearIcon: mergedAllowClear
4238
? isReactRenderable(allowClearConfig.clearIcon)

‎src/utils/warningPropsUtil.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,11 @@ function warningProps(props: SelectProps) {
7474

7575
// Customize `getInputElement` should not use `allowClear` & `placeholder`
7676
noteOnce(
77-
mode !== 'combobox' || !getInputElement || !allowClear || !placeholder,
77+
mode !== 'combobox' ||
78+
!getInputElement ||
79+
!allowClear ||
80+
(typeof allowClear === 'object' && allowClear.disabled) ||
81+
!placeholder,
7882
'Customize `getInputElement` should customize clear and placeholder logic instead of configuring `allowClear` and `placeholder`.',
7983
);
8084

‎tests/allowClear.test.tsx‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { fireEvent, render } from '@testing-library/react';
2+
import * as React from 'react';
3+
import Select from '../src';
4+
import { resetWarned } from '@rc-component/util';
5+
6+
describe.each([undefined, 'multiple', 'tags', 'combobox'] as const)(
7+
'allowClear mode=%s',
8+
(mode) => {
9+
it('disables the clear entry and restores the complete configuration', () => {
10+
const onClear = jest.fn();
11+
const onChange = jest.fn();
12+
const props = {
13+
mode,
14+
defaultValue: 'one',
15+
onClear,
16+
onChange,
17+
options: [{ value: 'one' }],
18+
suffix: 'suffix',
19+
};
20+
const config = Object.freeze({ clearIcon: 'custom', label: 'Erase', tabIndex: -1 });
21+
const { container, getByRole, queryByRole, rerender } = render(
22+
<Select {...props} allowClear={false} />,
23+
);
24+
const markup = container.innerHTML;
25+
rerender(<Select {...props} allowClear={{ ...config, disabled: true }} />);
26+
expect(container.innerHTML).toBe(markup);
27+
expect(queryByRole('button', { name: 'Erase' })).toBeNull();
28+
expect(onClear).not.toHaveBeenCalled();
29+
expect(onChange).not.toHaveBeenCalled();
30+
expect(container.querySelector('.rc-select-suffix')).toHaveTextContent('suffix');
31+
32+
rerender(<Select {...props} allowClear={config} />);
33+
const button = getByRole('button', { name: 'Erase' });
34+
expect(button).toHaveTextContent('custom');
35+
expect(button.tabIndex).toBe(-1);
36+
fireEvent.click(button);
37+
expect(onClear).toHaveBeenCalledTimes(1);
38+
expect(onChange).toHaveBeenCalledWith(
39+
mode === 'multiple' || mode === 'tags' ? [] : undefined,
40+
mode === 'multiple' || mode === 'tags' ? [] : undefined,
41+
);
42+
expect(queryByRole('button', { name: 'Erase' })).toBeNull();
43+
});
44+
},
45+
);
46+
47+
it('does not override component disabled or empty-value restrictions', () => {
48+
const { queryByRole, rerender } = render(
49+
<Select value="one" disabled allowClear={{ disabled: false }} />,
50+
);
51+
expect(queryByRole('button', { name: 'Clear' })).toBeNull();
52+
rerender(<Select allowClear={{ disabled: false }} />);
53+
expect(queryByRole('button', { name: 'Clear' })).toBeNull();
54+
});
55+
56+
it('updates the clear tabIndex without changing the input tabIndex', () => {
57+
const { getByRole, rerender } = render(
58+
<Select value="one" tabIndex={3} allowClear={{ tabIndex: -1 }} />,
59+
);
60+
expect(getByRole('button', { name: 'Clear' }).tabIndex).toBe(-1);
61+
expect(getByRole('combobox').tabIndex).toBe(3);
62+
rerender(<Select value="one" tabIndex={3} allowClear={{ tabIndex: 0, label: 'Erase' }} />);
63+
expect(getByRole('button', { name: 'Erase' }).tabIndex).toBe(0);
64+
expect(getByRole('combobox').tabIndex).toBe(3);
65+
});
66+
67+
it('treats a disabled clear configuration as false in custom-input warnings', () => {
68+
resetWarned();
69+
const warning = jest.spyOn(console, 'warn').mockImplementation(() => {});
70+
try {
71+
const props = {
72+
mode: 'combobox' as const,
73+
getInputElement: () => <input />,
74+
placeholder: 'Search',
75+
};
76+
const { rerender } = render(<Select {...props} allowClear={{ disabled: true }} />);
77+
expect(warning).not.toHaveBeenCalled();
78+
rerender(<Select {...props} allowClear />);
79+
expect(warning).toHaveBeenCalledWith(
80+
expect.stringContaining('Customize \x60getInputElement\x60 should customize clear'),
81+
);
82+
} finally {
83+
warning.mockRestore();
84+
resetWarned();
85+
}
86+
});

0 commit comments

Comments
 (0)