Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,20 @@ The release flow is handled by `@rc-component/np` through the `rc-np` command af
## License

@rc-component/select is released under the [MIT](./LICENSE) license.

### Clear configuration

`allowClear` accepts a boolean or an `AllowClearConfig` object from `@rc-component/util`:

```ts
interface AllowClearConfig {
clearIcon?: React.ReactNode;
disabled?: boolean;
tabIndex?: number;
label?: string;
}
```

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.

`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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
"dependencies": {
"@rc-component/overflow": "^1.0.0",
"@rc-component/trigger": "^3.0.0",
"@rc-component/util": "^1.13.0",
"@rc-component/util": "^1.14.0",
"@rc-component/virtual-list": "^1.2.0",
"clsx": "^2.1.1"
},
Expand Down
6 changes: 4 additions & 2 deletions src/BaseSelect/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { AlignType, BuildInPlacements } from '@rc-component/trigger';
import { clsx } from 'clsx';
import { getDOM, isNonNullable, useEvent } from '@rc-component/util';
import { type AllowClearConfig, getDOM, isNonNullable, useEvent } from '@rc-component/util';
import type { ScrollConfig, ScrollTo } from '@rc-component/virtual-list';
import * as React from 'react';
import { useAllowClear } from '../hooks/useAllowClear';
Expand Down Expand Up @@ -185,7 +185,7 @@ export interface BaseSelectProps
tokenSeparators?: string[] | ((input: string) => string[]);

// >>> Icons
allowClear?: boolean | { clearIcon?: React.ReactNode; label?: string };
allowClear?: boolean | AllowClearConfig;
prefix?: React.ReactNode;
/** @deprecated Please use `suffix` instead. */
suffixIcon?: RenderNode;
Expand Down Expand Up @@ -725,6 +725,7 @@ const BaseSelect = React.forwardRef<BaseSelectRef, BaseSelectProps>((props, ref)
allowClear: mergedAllowClear,
clearIcon: clearNode,
label: clearLabel,
tabIndex: clearTabIndex,
} = useAllowClear(
prefixCls,
displayValues,
Expand Down Expand Up @@ -768,6 +769,7 @@ const BaseSelect = React.forwardRef<BaseSelectRef, BaseSelectProps>((props, ref)
suffix={mergedSuffixIcon}
clearIcon={clearNode}
clearLabel={clearLabel}
clearTabIndex={clearTabIndex}
// Type or mode
multiple={multiple}
mode={mode}
Expand Down
3 changes: 3 additions & 0 deletions src/SelectInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface SelectInputProps extends Omit<React.HTMLAttributes<HTMLDivEleme
suffix?: React.ReactNode;
clearIcon?: React.ReactNode;
clearLabel?: string;
clearTabIndex?: number;
removeIcon?: RenderNode;
multiple?: boolean;
displayValues: DisplayValueType[];
Expand Down Expand Up @@ -89,6 +90,7 @@ export default React.forwardRef<SelectInputRef, SelectInputProps>(function Selec
suffix,
clearIcon,
clearLabel,
clearTabIndex,
children,

// Data
Expand Down Expand Up @@ -310,6 +312,7 @@ export default React.forwardRef<SelectInputRef, SelectInputProps>(function Selec
<button
type="button"
aria-label={clearLabel}
tabIndex={clearTabIndex}
className={clsx(`${prefixCls}-clear`, classNames?.clear)}
style={styles?.clear}
onMouseDown={(e) => {
Expand Down
26 changes: 11 additions & 15 deletions src/hooks/useAllowClear.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import type { DisplayValueType, Mode } from '../interface';
import type React from 'react';
import { isReactRenderable } from '@rc-component/util';
import {
type AllowClearConfig,
isReactRenderable,
useAllowClear as useClearConfig,
} from '@rc-component/util';
import { useMemo } from 'react';

export interface AllowClearConfig {
interface ClearInfo extends AllowClearConfig {
allowClear: boolean;
clearIcon: React.ReactNode;
label: string;
Expand All @@ -12,31 +16,23 @@ export interface AllowClearConfig {
export const useAllowClear = (
prefixCls: string,
displayValues: DisplayValueType[],
allowClear?: boolean | { clearIcon?: React.ReactNode; label?: string },
allowClear?: boolean | AllowClearConfig,
clearIcon?: React.ReactNode,
disabled: boolean = false,
mergedSearchValue?: string,
mode?: Mode,
): AllowClearConfig => {
// Convert boolean to object first
const allowClearConfig = useMemo<Partial<AllowClearConfig>>(() => {
if (typeof allowClear === 'boolean') {
return { allowClear };
}
if (allowClear && typeof allowClear === 'object') {
return allowClear;
}
return { allowClear: false };
}, [allowClear]);
): ClearInfo => {
const allowClearConfig = useClearConfig(allowClear);

return useMemo(() => {
const mergedAllowClear =
!disabled &&
allowClearConfig.allowClear !== false &&
!allowClearConfig.disabled &&
(displayValues.length || mergedSearchValue) &&
!(mode === 'combobox' && mergedSearchValue === '');

return {
...allowClearConfig,
allowClear: mergedAllowClear,
clearIcon: mergedAllowClear
? isReactRenderable(allowClearConfig.clearIcon)
Expand Down
6 changes: 5 additions & 1 deletion src/utils/warningPropsUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,11 @@ function warningProps(props: SelectProps) {

// Customize `getInputElement` should not use `allowClear` & `placeholder`
noteOnce(
mode !== 'combobox' || !getInputElement || !allowClear || !placeholder,
mode !== 'combobox' ||
!getInputElement ||
!allowClear ||
(typeof allowClear === 'object' && allowClear.disabled) ||
!placeholder,
'Customize `getInputElement` should customize clear and placeholder logic instead of configuring `allowClear` and `placeholder`.',
);

Expand Down
86 changes: 86 additions & 0 deletions tests/allowClear.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { fireEvent, render } from '@testing-library/react';
import * as React from 'react';
import Select from '../src';
import { resetWarned } from '@rc-component/util';

describe.each([undefined, 'multiple', 'tags', 'combobox'] as const)(
'allowClear mode=%s',
(mode) => {
it('disables the clear entry and restores the complete configuration', () => {
const onClear = jest.fn();
const onChange = jest.fn();
const props = {
mode,
defaultValue: 'one',
onClear,
onChange,
options: [{ value: 'one' }],
suffix: 'suffix',
};
const config = Object.freeze({ clearIcon: 'custom', label: 'Erase', tabIndex: -1 });
const { container, getByRole, queryByRole, rerender } = render(
<Select {...props} allowClear={false} />,
);
const markup = container.innerHTML;
rerender(<Select {...props} allowClear={{ ...config, disabled: true }} />);
expect(container.innerHTML).toBe(markup);
expect(queryByRole('button', { name: 'Erase' })).toBeNull();
expect(onClear).not.toHaveBeenCalled();
expect(onChange).not.toHaveBeenCalled();
expect(container.querySelector('.rc-select-suffix')).toHaveTextContent('suffix');

rerender(<Select {...props} allowClear={config} />);
const button = getByRole('button', { name: 'Erase' });
expect(button).toHaveTextContent('custom');
expect(button.tabIndex).toBe(-1);
fireEvent.click(button);
expect(onClear).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(
mode === 'multiple' || mode === 'tags' ? [] : undefined,
mode === 'multiple' || mode === 'tags' ? [] : undefined,
);
expect(queryByRole('button', { name: 'Erase' })).toBeNull();
});
},
);

it('does not override component disabled or empty-value restrictions', () => {
const { queryByRole, rerender } = render(
<Select value="one" disabled allowClear={{ disabled: false }} />,
);
expect(queryByRole('button', { name: 'Clear' })).toBeNull();
rerender(<Select allowClear={{ disabled: false }} />);
expect(queryByRole('button', { name: 'Clear' })).toBeNull();
});

it('updates the clear tabIndex without changing the input tabIndex', () => {
const { getByRole, rerender } = render(
<Select value="one" tabIndex={3} allowClear={{ tabIndex: -1 }} />,
);
expect(getByRole('button', { name: 'Clear' }).tabIndex).toBe(-1);
expect(getByRole('combobox').tabIndex).toBe(3);
rerender(<Select value="one" tabIndex={3} allowClear={{ tabIndex: 0, label: 'Erase' }} />);
expect(getByRole('button', { name: 'Erase' }).tabIndex).toBe(0);
expect(getByRole('combobox').tabIndex).toBe(3);
});

it('treats a disabled clear configuration as false in custom-input warnings', () => {
resetWarned();
const warning = jest.spyOn(console, 'warn').mockImplementation(() => {});
try {
const props = {
mode: 'combobox' as const,
getInputElement: () => <input />,
placeholder: 'Search',
};
const { rerender } = render(<Select {...props} allowClear={{ disabled: true }} />);
expect(warning).not.toHaveBeenCalled();
rerender(<Select {...props} allowClear />);
expect(warning).toHaveBeenCalledWith(
expect.stringContaining('Customize \x60getInputElement\x60 should customize clear'),
);
} finally {
warning.mockRestore();
resetWarned();
}
});
Loading