From 39c25856ec163f60d91a0414cf86f5f5a94239bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Zandarin?= Date: Thu, 13 Aug 2026 13:19:20 +0200 Subject: [PATCH 1/4] feat: support allowClear --- README.md | 2 + README.zh-CN.md | 2 + assets/index.less | 18 ++++++++ docs/api.md | 12 +++++ docs/demo/allow-clear.tsx | 11 +++++ docs/example.md | 4 ++ src/InputNumber.tsx | 44 ++++++++++++++++-- tests/allowClear.test.tsx | 96 +++++++++++++++++++++++++++++++++++++++ tests/semantic.test.tsx | 7 +++ 9 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 docs/demo/allow-clear.tsx create mode 100644 tests/allowClear.test.tsx diff --git a/README.md b/README.md index 5b01dc19..e10c5a52 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ Then open `http://localhost:8000`. | Property | Type | Default | Description | | --- | --- | --- | --- | +| allowClear | `boolean \| { clearIcon?: ReactNode; disabled?: boolean }` | `false` | Show a clear button, optionally with a custom icon or disabled state. | | autoFocus | `boolean` | `false` | Focus the input when mounted. | | changeOnBlur | `boolean` | `true` | Commit value changes on blur. | | changeOnWheel | `boolean` | `false` | Allow value changes from the mouse wheel. | @@ -95,6 +96,7 @@ Then open `http://localhost:8000`. | upHandler | `ReactNode` | - | Custom increment control. | | value | `T \| null` | - | Controlled value. | | onChange | `(value: T \| null) => void` | - | Triggered when the committed value changes. | +| onClear | `() => void` | - | Triggered when the clear button is clicked. | | onInput | `(text: string) => void` | - | Triggered when the raw input text changes. | | onPressEnter | `React.KeyboardEventHandler` | - | Triggered when Enter is pressed. | | onStep | `(value: T, info: { offset: number \| string; type: 'up' \| 'down'; emitter: 'handler' \| 'keyboard' \| 'wheel' }) => void` | - | Triggered when the value changes by step. | diff --git a/README.zh-CN.md b/README.zh-CN.md index 81bbe7b4..082ccd74 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -66,6 +66,7 @@ npm start | 参数 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | +| allowClear | `boolean \| { clearIcon?: ReactNode; disabled?: boolean }` | `false` | 显示清除按钮,可自定义图标或禁用清除操作。 | | autoFocus | `boolean` | `false` | 安装后聚焦输入。 | | changeOnBlur | `boolean` | `true` | 提交模糊值的变化。 | | changeOnWheel | `boolean` | `false` | 允许通过鼠标滚轮更改值。 | @@ -95,6 +96,7 @@ npm start | upHandler | `ReactNode` | - | 自定义增量控制。 | | value | `T \| null` | - | 受控值。 | | onChange | `(value: T \| null) => void` | - | 当提交的值改变时触发。 | +| onClear | `() => void` | - | 点击清除按钮时触发。 | | onInput | `(text: string) => void` | - | 当原始输入文本更改时触发。 | | onPressEnter | `React.KeyboardEventHandler` | - | 当按下 Enter 时触发。 | | onStep | `(value: T, info: { offset: number \| string; type: 'up' \| 'down'; emitter: 'handler' \| 'keyboard' \| 'wheel' }) => void` | - | 当值逐步变化时触发。 | diff --git a/assets/index.less b/assets/index.less index 08cebed8..7b4a6aa3 100644 --- a/assets/index.less +++ b/assets/index.less @@ -76,6 +76,24 @@ -moz-appearance: textfield; } + &-clear-icon { + flex: none; + align-self: center; + padding: 0; + line-height: 1; + cursor: pointer; + background: none; + border: 0; + + &-hidden { + visibility: hidden; + } + + &-has-suffix { + margin-inline-end: 4px; + } + } + &-actions { width: 20px; height: 100%; diff --git a/docs/api.md b/docs/api.md index 97406ba4..a5b42ba4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -76,6 +76,12 @@ nav: false Specifies that an InputNumber should automatically get focus when the page loads + + allowClear + boolean | { clearIcon?: ReactNode; disabled?: boolean } + false + Whether to show a clear button, optionally with a custom icon or disabled state + readOnly Boolean @@ -124,6 +130,12 @@ nav: Called when value of an InputNumber changed + + onClear + Function + + Called when the clear button is clicked + onBlur Function diff --git a/docs/demo/allow-clear.tsx b/docs/demo/allow-clear.tsx new file mode 100644 index 00000000..a1cd6529 --- /dev/null +++ b/docs/demo/allow-clear.tsx @@ -0,0 +1,11 @@ +import InputNumber from '@rc-component/input-number'; +import React from 'react'; +import '../../assets/index.less'; + +export default () => ( +
+ + + +
+); diff --git a/docs/example.md b/docs/example.md index 7b2ec5d6..65821c38 100644 --- a/docs/example.md +++ b/docs/example.md @@ -13,6 +13,10 @@ nav: +## allow-clear + + + ## combination-key-format diff --git a/src/InputNumber.tsx b/src/InputNumber.tsx index 012b7269..3878c55d 100644 --- a/src/InputNumber.tsx +++ b/src/InputNumber.tsx @@ -69,9 +69,10 @@ const getWheelDeltaY = (event: WheelEvent) => { } }; -type SemanticName = 'root' | 'actions' | 'input' | 'action' | 'prefix' | 'suffix'; +type SemanticName = 'root' | 'actions' | 'input' | 'action' | 'prefix' | 'suffix' | 'clear'; export interface InputNumberProps - extends Omit< + extends + Omit< React.InputHTMLAttributes, | 'value' | 'defaultValue' @@ -117,6 +118,12 @@ export interface InputNumberProps controls?: boolean; prefix?: React.ReactNode; suffix?: React.ReactNode; + allowClear?: + | boolean + | { + clearIcon?: React.ReactNode; + disabled?: boolean; + }; classNames?: Partial>; styles?: Partial>; @@ -137,6 +144,7 @@ export interface InputNumberProps onInput?: (text: string) => void; onChange?: (value: T | null) => void; + onClear?: () => void; onPressEnter?: React.KeyboardEventHandler; onStep?: ( @@ -174,6 +182,7 @@ const InputNumber = React.forwardRef((props, r prefix, suffix, + allowClear, stringMode, parser, @@ -182,6 +191,7 @@ const InputNumber = React.forwardRef((props, r decimalSeparator, onChange, + onClear, onInput, onPressEnter, onStep, @@ -706,6 +716,33 @@ const InputNumber = React.forwardRef((props, r ); + const clearConfig = typeof allowClear === 'object' ? allowClear : {}; + const showClear = + allowClear && !disabled && !readOnly && !clearConfig.disabled && !decimalValue.isEmpty(); + const clearIconCls = `${prefixCls}-clear-icon`; + const clearNode = allowClear && ( + + ); + // >>>>>> Render return (
((props, r {...restProps} /> - {suffix !== undefined && ( + {(allowClear || suffix !== undefined) && (
+ {clearNode} {suffix}
)} diff --git a/tests/allowClear.test.tsx b/tests/allowClear.test.tsx new file mode 100644 index 00000000..22b6f64c --- /dev/null +++ b/tests/allowClear.test.tsx @@ -0,0 +1,96 @@ +import { act, fireEvent, render } from '@testing-library/react'; +import * as React from 'react'; +import InputNumber from '../src'; + +describe('InputNumber.AllowClear', () => { + it('clears an uncontrolled value and calls change callbacks', () => { + const calls: string[] = []; + const onChange = jest.fn(() => calls.push('change')); + const onClear = jest.fn(() => calls.push('clear')); + const { container } = render( + , + ); + + fireEvent.click(container.querySelector('.rc-input-number-clear-icon')); + + expect(container.querySelector('input')).toHaveValue(''); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(null); + expect(onClear).toHaveBeenCalledTimes(1); + expect(calls).toEqual(['change', 'clear']); + }); + + it('notifies without overriding a controlled value', () => { + const onChange = jest.fn(); + const onClear = jest.fn(); + const { container } = render( + , + ); + + fireEvent.click(container.querySelector('.rc-input-number-clear-icon')); + + expect(container.querySelector('input')).toHaveValue('123'); + expect(onChange).toHaveBeenCalledWith(null); + expect(onClear).toHaveBeenCalledTimes(1); + }); + + it('supports clearing zero', () => { + const onChange = jest.fn(); + const { container } = render(); + const clearButton = container.querySelector('.rc-input-number-clear-icon'); + + expect(clearButton).not.toHaveClass('rc-input-number-clear-icon-hidden'); + fireEvent.click(clearButton); + + expect(container.querySelector('input')).toHaveValue(''); + expect(onChange).toHaveBeenCalledWith(null); + }); + + it('supports a custom clear icon', () => { + const { getByTestId } = render( + clear }} + defaultValue={1} + />, + ); + + expect(getByTestId('custom-clear')).toBeInTheDocument(); + }); + + it.each([ + ['disabled', { disabled: true }], + ['readOnly', { readOnly: true }], + ['allowClear.disabled', { allowClear: { disabled: true } }], + ])('hides the clear button when %s', (_name, extraProps) => { + const { container } = render(); + + expect(container.querySelector('.rc-input-number-clear-icon')).toHaveClass( + 'rc-input-number-clear-icon-hidden', + ); + }); + + it('preserves input focus on pointer interaction', () => { + const onBlur = jest.fn(); + const { container } = render(); + const input = container.querySelector('input'); + const clearButton = container.querySelector('.rc-input-number-clear-icon'); + + act(() => input.focus()); + fireEvent.mouseDown(clearButton); + fireEvent.click(clearButton); + + expect(input).toHaveFocus(); + expect(onBlur).not.toHaveBeenCalled(); + }); + + it('uses an accessible keyboard-focusable button', () => { + const onChange = jest.fn(); + const { getByRole } = render(); + const clearButton = getByRole('button', { name: 'Clear Value' }); + + act(() => clearButton.focus()); + expect(clearButton).toHaveFocus(); + fireEvent.click(clearButton); + expect(onChange).toHaveBeenCalledWith(null); + }); +}); diff --git a/tests/semantic.test.tsx b/tests/semantic.test.tsx index bda9a6ec..975d49e9 100644 --- a/tests/semantic.test.tsx +++ b/tests/semantic.test.tsx @@ -8,6 +8,7 @@ describe('InputNumber.Semantic', () => { prefix: 'test-prefix', input: 'test-input', suffix: 'test-suffix', + clear: 'test-clear', actions: 'test-actions', action: 'test-action', }; @@ -16,6 +17,7 @@ describe('InputNumber.Semantic', () => { prefix: { color: 'rgb(255, 0, 0)' }, input: { color: 'rgb(0, 0, 255)' }, suffix: { color: 'rgb(0, 128, 0)' }, + clear: { color: 'rgb(128, 0, 128)' }, actions: { color: 'rgb(255, 255, 0)' }, action: { color: 'rgb(255, 192, 203)' }, }; @@ -24,6 +26,8 @@ describe('InputNumber.Semantic', () => { prefixCls="rc-input-number" prefix="prefix" suffix={
suffix
} + allowClear + defaultValue={1} styles={testStyles} classNames={testClassNames} />, @@ -33,18 +37,21 @@ describe('InputNumber.Semantic', () => { const input = container.querySelector('input')!; const prefix = container.querySelector('.rc-input-number-prefix')!; const suffix = container.querySelector('.rc-input-number-suffix')!; + const clear = container.querySelector('.rc-input-number-clear-icon')!; const actions = container.querySelector('.rc-input-number-actions')!; const action = container.querySelector('.rc-input-number-action')!; expect(root).toHaveClass(testClassNames.root); expect(input).toHaveClass(testClassNames.input); expect(prefix).toHaveClass(testClassNames.prefix); expect(suffix).toHaveClass(testClassNames.suffix); + expect(clear).toHaveClass(testClassNames.clear); expect(actions).toHaveClass(testClassNames.actions); expect(action).toHaveClass(testClassNames.action); expect(root).toHaveStyle(testStyles.root); expect(prefix).toHaveStyle(testStyles.prefix); expect(input).toHaveStyle(testStyles.input); expect(suffix).toHaveStyle(testStyles.suffix); + expect(clear).toHaveStyle(testStyles.clear); expect(actions).toHaveStyle(testStyles.actions); expect(action).toHaveStyle(testStyles.action); }); From 026c27cb2301eb560ed27006edcdfacea7cab058 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Zandarin?= Date: Thu, 13 Aug 2026 13:30:07 +0200 Subject: [PATCH 2/4] fix: isolate clear button keyboard events --- src/InputNumber.tsx | 1 + tests/allowClear.test.tsx | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/InputNumber.tsx b/src/InputNumber.tsx index 3878c55d..141b5ebb 100644 --- a/src/InputNumber.tsx +++ b/src/InputNumber.tsx @@ -734,6 +734,7 @@ const InputNumber = React.forwardRef((props, r )} style={styles?.clear} onMouseDown={(event) => event.preventDefault()} + onKeyDown={(event) => event.stopPropagation()} onClick={() => { triggerValueUpdate(getMiniDecimal(null), false); onClear?.(); diff --git a/tests/allowClear.test.tsx b/tests/allowClear.test.tsx index 22b6f64c..3194d438 100644 --- a/tests/allowClear.test.tsx +++ b/tests/allowClear.test.tsx @@ -93,4 +93,38 @@ describe('InputNumber.AllowClear', () => { fireEvent.click(clearButton); expect(onChange).toHaveBeenCalledWith(null); }); + + it('isolates clear button keyboard events from input handlers', () => { + const onChange = jest.fn(); + const onPressEnter = jest.fn(); + const onStep = jest.fn(); + const { getByRole } = render( + , + ); + const input = getByRole('spinbutton'); + const clearButton = getByRole('button', { name: 'Clear Value' }); + + fireEvent.change(input, { target: { value: '2' } }); + onChange.mockClear(); + act(() => clearButton.focus()); + + ['Enter', 'ArrowUp', 'ArrowDown'].forEach((key) => { + fireEvent.keyDown(clearButton, { key }); + }); + + expect(onPressEnter).not.toHaveBeenCalled(); + expect(onStep).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + expect(input).toHaveValue('2'); + + fireEvent.click(clearButton); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(null); + }); }); From 3f30bd52de0ac9c346e817134bbfe3695454d6d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Zandarin?= Date: Sat, 15 Aug 2026 16:09:34 +0200 Subject: [PATCH 3/4] fix: harden allowClear behavior and accessibility --- README.md | 2 +- README.zh-CN.md | 2 +- docs/api.md | 4 +- docs/demo/allow-clear.tsx | 2 +- src/InputNumber.tsx | 63 +++++++++-- tests/allowClear.test.tsx | 215 +++++++++++++++++++++++++++++++++++--- 6 files changed, 257 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index e10c5a52..1faa98b1 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Then open `http://localhost:8000`. | Property | Type | Default | Description | | --- | --- | --- | --- | -| allowClear | `boolean \| { clearIcon?: ReactNode; disabled?: boolean }` | `false` | Show a clear button, optionally with a custom icon or disabled state. | +| allowClear | `boolean \| { clearIcon?: ReactNode; disabled?: boolean; label?: string }` | `false` | Show a clear button, optionally with a custom icon, disabled state, or accessible label. | | autoFocus | `boolean` | `false` | Focus the input when mounted. | | changeOnBlur | `boolean` | `true` | Commit value changes on blur. | | changeOnWheel | `boolean` | `false` | Allow value changes from the mouse wheel. | diff --git a/README.zh-CN.md b/README.zh-CN.md index 082ccd74..4b104877 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -66,7 +66,7 @@ npm start | 参数 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | -| allowClear | `boolean \| { clearIcon?: ReactNode; disabled?: boolean }` | `false` | 显示清除按钮,可自定义图标或禁用清除操作。 | +| allowClear | `boolean \| { clearIcon?: ReactNode; disabled?: boolean; label?: string }` | `false` | 显示清除按钮,可自定义图标、禁用清除操作或设置无障碍标签。 | | autoFocus | `boolean` | `false` | 安装后聚焦输入。 | | changeOnBlur | `boolean` | `true` | 提交模糊值的变化。 | | changeOnWheel | `boolean` | `false` | 允许通过鼠标滚轮更改值。 | diff --git a/docs/api.md b/docs/api.md index a5b42ba4..f28234f3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -78,9 +78,9 @@ nav: allowClear - boolean | { clearIcon?: ReactNode; disabled?: boolean } + boolean | { clearIcon?: ReactNode; disabled?: boolean; label?: string } false - Whether to show a clear button, optionally with a custom icon or disabled state + Whether to show a clear button, optionally with a custom icon, disabled state, or accessible label readOnly diff --git a/docs/demo/allow-clear.tsx b/docs/demo/allow-clear.tsx index a1cd6529..dcdc193b 100644 --- a/docs/demo/allow-clear.tsx +++ b/docs/demo/allow-clear.tsx @@ -5,7 +5,7 @@ import '../../assets/index.less'; export default () => (
- +
); diff --git a/src/InputNumber.tsx b/src/InputNumber.tsx index 141b5ebb..16e01230 100644 --- a/src/InputNumber.tsx +++ b/src/InputNumber.tsx @@ -123,6 +123,7 @@ export interface InputNumberProps | { clearIcon?: React.ReactNode; disabled?: boolean; + label?: string; }; classNames?: Partial>; styles?: Partial>; @@ -295,6 +296,7 @@ const InputNumber = React.forwardRef((props, r // >>> Formatter const inputValueRef = React.useRef(''); + const inputValueUpdateRef = React.useRef(0); const mergedFormatter = React.useCallback( (number: string, userTyping: boolean) => { if (formatter) { @@ -455,6 +457,8 @@ const InputNumber = React.forwardRef((props, r // >>> Collect input value const collectInputValue = (inputStr: string) => { + const inputValueUpdateId = (inputValueUpdateRef.current += 1); + recordCursor(); // Update inputValue in case input can not parse as number @@ -477,6 +481,10 @@ const InputNumber = React.forwardRef((props, r // optimize for chinese input experience // https://github.com/ant-design/ant-design/issues/8196 onNextPromise(() => { + if (inputValueUpdateId !== inputValueUpdateRef.current) { + return; + } + let nextInputStr = inputStr; if (!parser) { nextInputStr = inputStr.replace(/。/g, '.'); @@ -643,7 +651,12 @@ const InputNumber = React.forwardRef((props, r }, [changeOnWheel, focus, onInternalWheel]); // >>> Focus & Blur - const onBlur = () => { + const onBlur: React.FocusEventHandler = (event) => { + // Moving focus from an internal control back to the input does not blur InputNumber. + if (event.target !== inputRef.current && event.relatedTarget === inputRef.current) { + return; + } + if (changeOnBlur) { flushInputValue(false); } @@ -716,14 +729,47 @@ const InputNumber = React.forwardRef((props, r ); - const clearConfig = typeof allowClear === 'object' ? allowClear : {}; - const showClear = - allowClear && !disabled && !readOnly && !clearConfig.disabled && !decimalValue.isEmpty(); + const clearConfig = allowClear && typeof allowClear === 'object' ? allowClear : {}; + const clearDisabled = !!(disabled || readOnly || clearConfig.disabled); + const showClear = !!allowClear && !clearDisabled && String(inputValue).length > 0; const clearIconCls = `${prefixCls}-clear-icon`; + + const onClearKeyDown: React.KeyboardEventHandler = (event) => { + const isStepKey = ['Up', 'ArrowUp', 'Down', 'ArrowDown'].includes(event.key); + if (event.key === 'Enter' || (keyboard !== false && isStepKey)) { + event.stopPropagation(); + } + }; + + const onClearClick = () => { + if (!showClear) { + return; + } + + userTypingRef.current = false; + inputValueRef.current = ''; + inputValueUpdateRef.current += 1; + + const emptyValue = getMiniDecimal(null); + + // `triggerValueUpdate` only refreshes the display when the decimal value changes. + // Clear raw input such as `-`, or restore the source value in controlled mode. + if (value !== undefined) { + setInputValue(decimalValue, false); + } else if (decimalValue.isEmpty()) { + setInputValue(emptyValue, false); + } + + inputRef.current?.focus(); + triggerValueUpdate(emptyValue, false); + onClear?.(); + }; + const clearNode = allowClear && ( diff --git a/tests/allowClear.test.tsx b/tests/allowClear.test.tsx index 3194d438..54095a9b 100644 --- a/tests/allowClear.test.tsx +++ b/tests/allowClear.test.tsx @@ -3,6 +3,12 @@ import * as React from 'react'; import InputNumber from '../src'; describe('InputNumber.AllowClear', () => { + it('treats a null allowClear value as disabled', () => { + const { container } = render(); + + expect(container.querySelector('.rc-input-number-clear-icon')).not.toBeInTheDocument(); + }); + it('clears an uncontrolled value and calls change callbacks', () => { const calls: string[] = []; const onChange = jest.fn(() => calls.push('change')); @@ -20,6 +26,78 @@ describe('InputNumber.AllowClear', () => { expect(calls).toEqual(['change', 'clear']); }); + it('clears raw input when the decimal value is already empty', () => { + const onChange = jest.fn(); + const onClear = jest.fn(); + const { container, getByRole } = render( + , + ); + const input = getByRole('spinbutton'); + const clearButton = container.querySelector('.rc-input-number-clear-icon'); + + fireEvent.change(input, { target: { value: '-' } }); + + expect(clearButton).not.toHaveClass('rc-input-number-clear-icon-hidden'); + expect(clearButton).not.toBeDisabled(); + + fireEvent.click(clearButton); + + expect(input).toHaveValue(''); + expect(onChange).not.toHaveBeenCalled(); + expect(onClear).toHaveBeenCalledTimes(1); + }); + + it('does not restore pending input normalization after clearing', () => { + jest.useFakeTimers(); + + try { + const onChange = jest.fn(); + const { container, getByRole } = render(); + const input = getByRole('spinbutton'); + + fireEvent.change(input, { target: { value: '8。1' } }); + onChange.mockClear(); + + fireEvent.click(container.querySelector('.rc-input-number-clear-icon')); + + expect(input).toHaveValue(''); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(null); + + act(() => jest.runOnlyPendingTimers()); + + expect(input).toHaveValue(''); + expect(onChange).toHaveBeenCalledTimes(1); + } finally { + jest.clearAllTimers(); + jest.useRealTimers(); + } + }); + + it('uses the null contract without parsing empty text', () => { + const parser = jest.fn((text: string) => (text === '' ? 0 : Number(text))); + const formatter = jest.fn((nextValue, { input }) => input || String(nextValue ?? '')); + const onChange = jest.fn(); + const { container } = render( + , + ); + + parser.mockClear(); + formatter.mockClear(); + fireEvent.click(container.querySelector('.rc-input-number-clear-icon')); + + expect(container.querySelector('input')).toHaveValue(''); + expect(onChange).toHaveBeenCalledWith(null); + expect(parser).not.toHaveBeenCalled(); + expect(formatter.mock.calls.at(-1)?.[1]).toEqual({ userTyping: false, input: '' }); + }); + it('notifies without overriding a controlled value', () => { const onChange = jest.fn(); const onClear = jest.fn(); @@ -34,6 +112,18 @@ describe('InputNumber.AllowClear', () => { expect(onClear).toHaveBeenCalledTimes(1); }); + it('clears when controlled state accepts null', () => { + const ControlledInputNumber = () => { + const [currentValue, setCurrentValue] = React.useState(123); + return allowClear value={currentValue} onChange={setCurrentValue} />; + }; + const { container } = render(); + + fireEvent.click(container.querySelector('.rc-input-number-clear-icon')); + + expect(container.querySelector('input')).toHaveValue(''); + }); + it('supports clearing zero', () => { const onChange = jest.fn(); const { container } = render(); @@ -57,16 +147,69 @@ describe('InputNumber.AllowClear', () => { expect(getByTestId('custom-clear')).toBeInTheDocument(); }); + it('supports a localized accessible label that takes precedence over the icon', () => { + const { getByRole } = render( + clear, + label: 'Effacer', + }} + defaultValue={1} + />, + ); + + expect(getByRole('button', { name: 'Effacer' })).toHaveAttribute('aria-label', 'Effacer'); + }); + it.each([ ['disabled', { disabled: true }], ['readOnly', { readOnly: true }], ['allowClear.disabled', { allowClear: { disabled: true } }], - ])('hides the clear button when %s', (_name, extraProps) => { - const { container } = render(); + ])('disables the clear action when %s', (_name, extraProps) => { + const onChange = jest.fn(); + const onClear = jest.fn(); + const { container } = render( + , + ); + const clearButton = container.querySelector('.rc-input-number-clear-icon'); - expect(container.querySelector('.rc-input-number-clear-icon')).toHaveClass( - 'rc-input-number-clear-icon-hidden', + expect(clearButton).toHaveClass('rc-input-number-clear-icon-hidden'); + expect(clearButton).toBeDisabled(); + + fireEvent.click(clearButton); + + expect(container.querySelector('input')).toHaveValue('1'); + expect(onChange).not.toHaveBeenCalled(); + expect(onClear).not.toHaveBeenCalled(); + }); + + it('keeps an empty clear action inert when semantic styles reveal it', () => { + const onChange = jest.fn(); + const onClear = jest.fn(); + const { container } = render( + , ); + const clearButton = container.querySelector('.rc-input-number-clear-icon'); + + expect(clearButton).toHaveClass('rc-input-number-clear-icon-hidden'); + expect(clearButton).toBeDisabled(); + + fireEvent.click(clearButton); + + expect(onChange).not.toHaveBeenCalled(); + expect(onClear).not.toHaveBeenCalled(); }); it('preserves input focus on pointer interaction', () => { @@ -83,38 +226,47 @@ describe('InputNumber.AllowClear', () => { expect(onBlur).not.toHaveBeenCalled(); }); - it('uses an accessible keyboard-focusable button', () => { + it('uses an accessible button and returns focus after keyboard activation', () => { const onChange = jest.fn(); const { getByRole } = render(); - const clearButton = getByRole('button', { name: 'Clear Value' }); + const input = getByRole('spinbutton'); + const clearButton = getByRole('button', { name: 'Clear' }); + expect(clearButton).toHaveAttribute('type', 'button'); act(() => clearButton.focus()); expect(clearButton).toHaveFocus(); fireEvent.click(clearButton); + expect(onChange).toHaveBeenCalledWith(null); + expect(input).toHaveFocus(); + expect(clearButton).toBeDisabled(); + expect(clearButton).toHaveClass('rc-input-number-clear-icon-hidden'); }); - it('isolates clear button keyboard events from input handlers', () => { + it('isolates conflicting keys but lets unrelated keys propagate', () => { const onChange = jest.fn(); const onPressEnter = jest.fn(); const onStep = jest.fn(); + const onParentKeyDown = jest.fn(); const { getByRole } = render( - , +
+ +
, ); const input = getByRole('spinbutton'); - const clearButton = getByRole('button', { name: 'Clear Value' }); + const clearButton = getByRole('button', { name: 'Clear' }); fireEvent.change(input, { target: { value: '2' } }); onChange.mockClear(); act(() => clearButton.focus()); - ['Enter', 'ArrowUp', 'ArrowDown'].forEach((key) => { + ['Enter', 'Up', 'ArrowUp', 'Down', 'ArrowDown'].forEach((key) => { fireEvent.keyDown(clearButton, { key }); }); @@ -122,9 +274,40 @@ describe('InputNumber.AllowClear', () => { expect(onStep).not.toHaveBeenCalled(); expect(onChange).not.toHaveBeenCalled(); expect(input).toHaveValue('2'); + expect(onParentKeyDown).not.toHaveBeenCalled(); + + fireEvent.keyDown(clearButton, { key: 'Escape' }); + fireEvent.keyDown(clearButton, { key: ' ' }); + + expect(onParentKeyDown.mock.calls.map(([event]) => event.key)).toEqual(['Escape', ' ']); fireEvent.click(clearButton); expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith(null); + expect(input).toHaveValue('1'); + expect(input).toHaveFocus(); + }); + + it('lets step keys propagate when keyboard stepping is disabled', () => { + const onParentKeyDown = jest.fn(); + const onStep = jest.fn(); + const { getByRole } = render( +
+ +
, + ); + const clearButton = getByRole('button', { name: 'Clear' }); + + ['Up', 'ArrowUp', 'Down', 'ArrowDown'].forEach((key) => { + fireEvent.keyDown(clearButton, { key }); + }); + + expect(onStep).not.toHaveBeenCalled(); + expect(onParentKeyDown.mock.calls.map(([event]) => event.key)).toEqual([ + 'ArrowUp', + 'ArrowUp', + 'ArrowDown', + 'ArrowDown', + ]); }); }); From ed0c30ea71c15dc60dac17c277ab5c7ec3b0fa85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Zandarin?= Date: Sat, 15 Aug 2026 16:26:49 +0200 Subject: [PATCH 4/4] fix: preserve allowClear across internal focus changes --- src/InputNumber.tsx | 4 ++-- tests/allowClear.test.tsx | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/InputNumber.tsx b/src/InputNumber.tsx index 16e01230..23dac9f9 100644 --- a/src/InputNumber.tsx +++ b/src/InputNumber.tsx @@ -652,8 +652,8 @@ const InputNumber = React.forwardRef((props, r // >>> Focus & Blur const onBlur: React.FocusEventHandler = (event) => { - // Moving focus from an internal control back to the input does not blur InputNumber. - if (event.target !== inputRef.current && event.relatedTarget === inputRef.current) { + // Moving focus between internal controls does not blur InputNumber. + if (event.relatedTarget instanceof Node && rootRef.current?.contains(event.relatedTarget)) { return; } diff --git a/tests/allowClear.test.tsx b/tests/allowClear.test.tsx index 54095a9b..3f66266a 100644 --- a/tests/allowClear.test.tsx +++ b/tests/allowClear.test.tsx @@ -47,6 +47,27 @@ describe('InputNumber.AllowClear', () => { expect(onClear).toHaveBeenCalledTimes(1); }); + it('keeps raw input clearable when focus moves to the clear button', () => { + const onClear = jest.fn(); + const { container, getByRole } = render(); + const input = getByRole('spinbutton'); + const clearButton = container.querySelector('.rc-input-number-clear-icon'); + + act(() => input.focus()); + fireEvent.change(input, { target: { value: '-' } }); + act(() => clearButton.focus()); + + expect(clearButton).toHaveFocus(); + expect(clearButton).not.toBeDisabled(); + expect(input).toHaveValue('-'); + + fireEvent.click(clearButton); + + expect(input).toHaveValue(''); + expect(input).toHaveFocus(); + expect(onClear).toHaveBeenCalledTimes(1); + }); + it('does not restore pending input normalization after clearing', () => { jest.useFakeTimers(); @@ -136,6 +157,19 @@ describe('InputNumber.AllowClear', () => { expect(onChange).toHaveBeenCalledWith(null); }); + it('clears to null when precision is configured', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + + fireEvent.click(container.querySelector('.rc-input-number-clear-icon')); + + expect(container.querySelector('input')).toHaveValue(''); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(null); + }); + it('supports a custom clear icon', () => { const { getByTestId } = render(