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
37 changes: 37 additions & 0 deletions docs/demo/useAllowClear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# useAllowClear

Normalize a boolean or object clear configuration into a single `AllowClearConfig` object.

```tsx | pure
import { useAllowClear } from '@rc-component/util';
import type { AllowClearConfig } from '@rc-component/util';

function useClearConfig(allowClear?: boolean | AllowClearConfig) {
return useAllowClear(allowClear);
}
```

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

function useAllowClear(
allowClear?: boolean | AllowClearConfig,
): AllowClearConfig;
```

| Input | Result |
| ---------------------- | ---------------------------------------------------------- |
| `undefined` or `false` | `{ disabled: true }` |
| `true` | `{ disabled: false }` |
| Object | A copy of the object with `disabled` defaulting to `false` |

All four fields are optional. Object fields are preserved, including `tabIndex: 0`, an empty `label`, and `clearIcon: null`. The input object is never modified. The result is memoized by the `allowClear` value or object reference.

Components should merge configuration and apply their own defaults **before** calling this hook. For example, a component that enables clearing by default can pass `allowClear ?? true`.

Components should treat `config.disabled` as disabling the clear entry: no clear button or space reserved solely for it. They remain responsible for rendering, default icons, localized `aria-label` text, button `tabIndex`, and their existing disabled, read-only, value, and event behavior. This hook only normalizes configuration.
23 changes: 23 additions & 0 deletions src/hooks/useAllowClear.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import * as React from 'react';

export interface AllowClearConfig {
clearIcon?: React.ReactNode;
/** Disable the clear entry without rendering a button or reserving space. */
disabled?: boolean;
tabIndex?: number;
/** Accessible name for the clear button. */
label?: string;
}

/** Normalize clear configuration. Components should apply their defaults first. */
export default function useAllowClear(
allowClear?: boolean | AllowClearConfig,
): AllowClearConfig {
return React.useMemo(
() =>
allowClear && typeof allowClear === 'object'
? { ...allowClear, disabled: allowClear.disabled ?? false }
: { disabled: !allowClear },
[allowClear],
);
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export { default as useAllowClear } from './hooks/useAllowClear';
export type { AllowClearConfig } from './hooks/useAllowClear';
export { default as useEvent } from './hooks/useEvent';
export { default as useMergedState } from './hooks/useMergedState';
export { default as useControlledState } from './hooks/useControlledState';
Expand Down
92 changes: 92 additions & 0 deletions tests/useAllowClear.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { renderHook } from '@testing-library/react';
import * as React from 'react';
import { useAllowClear } from '../src';
import type { AllowClearConfig } from '../src';

describe('useAllowClear', () => {
it.each<[boolean | AllowClearConfig | undefined, AllowClearConfig]>([
[undefined, { disabled: true }],
[false, { disabled: true }],
[true, { disabled: false }],
[{}, { disabled: false }],
[{ disabled: undefined }, { disabled: false }],
[{ disabled: false }, { disabled: false }],
[{ disabled: true }, { disabled: true }],
[{ clearIcon: null }, { clearIcon: null, disabled: false }],
[{ tabIndex: -1 }, { tabIndex: -1, disabled: false }],
[{ tabIndex: 0 }, { tabIndex: 0, disabled: false }],
[{ label: '' }, { label: '', disabled: false }],
[
{ label: 'Clear selection' },
{ label: 'Clear selection', disabled: false },
],
])('normalizes %p to %p', (allowClear, expected) => {
const { result } = renderHook(() => useAllowClear(allowClear));
expect(result.current).toEqual(expected);
});

it('disables clearing when called without an argument', () => {
const { result } = renderHook(() => useAllowClear());
expect(result.current).toEqual({ disabled: true });
});

it('preserves all fields without mutating the input during toggles', () => {
const clearIcon = <span>Clear</span>;
const config: AllowClearConfig = Object.freeze({
clearIcon,
tabIndex: 0,
label: 'Clear selection',
});
const disabledConfig = Object.freeze({ ...config, disabled: true });
const { result, rerender } = renderHook(
(allowClear: boolean | AllowClearConfig) => useAllowClear(allowClear),
{ initialProps: config },
);

expect(result.current).toEqual({ ...config, disabled: false });
expect(result.current).not.toBe(config);
expect(result.current.clearIcon).toBe(clearIcon);
expect(config).not.toHaveProperty('disabled');

rerender(disabledConfig);
expect(result.current).toEqual(disabledConfig);
expect(result.current.clearIcon).toBe(clearIcon);

rerender(false);
expect(result.current).toEqual({ disabled: true });

rerender(config);
expect(result.current).toEqual({ ...config, disabled: false });
expect(result.current.clearIcon).toBe(clearIcon);
expect(config).not.toHaveProperty('disabled');
expect(disabledConfig.disabled).toBe(true);
});

it.each([undefined, false, true, {}])(
'keeps the result stable for unchanged input %p',
allowClear => {
const { result, rerender } = renderHook(() => useAllowClear(allowClear));
const previous = result.current;
rerender();
expect(result.current).toBe(previous);
},
);

it('updates when the configuration changes', () => {
const { result, rerender } = renderHook(
(allowClear: AllowClearConfig) => useAllowClear(allowClear),
{ initialProps: { clearIcon: 'Old', tabIndex: -1, label: 'Clear' } },
);
const previous = result.current;
const config = {
clearIcon: 'New',
disabled: false,
tabIndex: 0,
label: 'Clear selection',
};

rerender(config);
expect(result.current).not.toBe(previous);
expect(result.current).toEqual(config);
});
});
Loading