diff --git a/src/PickerInput/RangePicker.tsx b/src/PickerInput/RangePicker.tsx index 6c6f83496..4cb38d457 100644 --- a/src/PickerInput/RangePicker.tsx +++ b/src/PickerInput/RangePicker.tsx @@ -678,7 +678,15 @@ function RangePicker( // ======================================================== // ======================== Change ======================== - const onSelectorChange = (date: DateType, index: number) => { + const onSelectorChange = (date: DateType | null, index: number) => { + if (!date) { + resetRangeValueChange(); + triggerSubmitChange(allowEmpty[index] ? fillCalendarValue(null, index) : null); + triggerOpen(false, { force: true }); + onClear?.(); + return; + } + triggerRangeValueChange(index, 'input', date); }; diff --git a/src/PickerInput/Selector/Input.tsx b/src/PickerInput/Selector/Input.tsx index d52d64afd..c9f7a9471 100644 --- a/src/PickerInput/Selector/Input.tsx +++ b/src/PickerInput/Selector/Input.tsx @@ -46,6 +46,7 @@ export interface InputProps extends Omit((props, ref) => { @@ -65,6 +66,7 @@ const Input = React.forwardRef((props, ref) => { preserveInvalidOnBlur = false, invalid, clearIcon, + clearable, // Pass to input ...restProps } = props; @@ -142,10 +144,18 @@ const Input = React.forwardRef((props, ref) => { // Directly trigger `onChange` if `format` is empty const onInternalChange: React.ChangeEventHandler = (event) => { + const text = event.target.value; + + // Empty text is a valid clear action when the picker is clearable. + // Handle it before the mask logic, which normally ignores invalid text. + if (clearable && !text) { + setInputValue(text); + onChange(text); + return; + } + // Hack `onChange` with format to do nothing if (!format) { - const text = event.target.value; - onModify(text); setInputValue(text); onChange(text); diff --git a/src/PickerInput/Selector/RangeSelector.tsx b/src/PickerInput/Selector/RangeSelector.tsx index 9a0034548..88b7a8cc6 100644 --- a/src/PickerInput/Selector/RangeSelector.tsx +++ b/src/PickerInput/Selector/RangeSelector.tsx @@ -30,7 +30,7 @@ export interface RangeSelectorProps extends SelectorProps void; + onChange: (date: DateType | null, index?: number) => void; disabled: [boolean, boolean]; diff --git a/src/PickerInput/Selector/SingleSelector/index.tsx b/src/PickerInput/Selector/SingleSelector/index.tsx index 763a982aa..803638231 100644 --- a/src/PickerInput/Selector/SingleSelector/index.tsx +++ b/src/PickerInput/Selector/SingleSelector/index.tsx @@ -132,7 +132,12 @@ function SingleSelector( const rootProps = useRootProps(restProps); // ======================== Change ======================== - const onSingleChange = (date: DateType) => { + const onSingleChange = (date: DateType | null) => { + if (!date) { + onClear(); + return; + } + onChange([date], 'input'); }; diff --git a/src/PickerInput/Selector/hooks/useInputProps.ts b/src/PickerInput/Selector/hooks/useInputProps.ts index 605551afb..333c16f1b 100644 --- a/src/PickerInput/Selector/hooks/useInputProps.ts +++ b/src/PickerInput/Selector/hooks/useInputProps.ts @@ -1,4 +1,4 @@ -import { pickAttrs, warning } from '@rc-component/util'; +import { isReactRenderable, pickAttrs, warning } from '@rc-component/util'; import * as React from 'react'; import type { SelectorProps } from '../../../interface'; import { formatValue } from '../../../utils/dateUtil'; @@ -27,6 +27,7 @@ export default function useInputProps( | 'autoComplete' | 'open' | 'picker' + | 'clearIcon' > & { id?: string | string[]; value?: DateType[]; @@ -73,8 +74,11 @@ export default function useInputProps( allHelp, picker, + clearIcon, } = props; + const canClear = isReactRenderable(clearIcon); + // ======================== Parser ======================== const parseDate = (str: string, formatStr: string) => { const parsed = generateConfig.locale.parse(locale.locale, str, [formatStr]); @@ -161,6 +165,8 @@ export default function useInputProps( disabled: getProp(disabled), + clearable: canClear, + onFocus: (event) => { onFocus(event, index); }, @@ -184,6 +190,12 @@ export default function useInputProps( return; } + if (!text && canClear) { + onInvalid(false, index); + onChange(null, index); + return; + } + // Tell outer that the value typed is invalid. // If text is empty, it means valid. onInvalid(!!text, index); diff --git a/tests/picker.spec.tsx b/tests/picker.spec.tsx index 01923a0c5..5e9af928b 100644 --- a/tests/picker.spec.tsx +++ b/tests/picker.spec.tsx @@ -323,6 +323,79 @@ describe('Picker.Basic', () => { }); }); + it('clears a selected value when the input text is removed', () => { + const onChange = jest.fn(); + const onClear = jest.fn(); + const { container } = render( + , + ); + + openPicker(container); + fireEvent.change(container.querySelector('input'), { target: { value: '' } }); + + expect(onChange).toHaveBeenCalledWith(null, null); + expect(onClear).toHaveBeenCalledTimes(1); + expect(container.querySelector('input')).toHaveValue(''); + expect(isOpen()).toBeFalsy(); + }); + + [true, false, { clearIcon: 0 }].forEach((allowClear) => { + it(`clears invalid mask text without a selected value: ${JSON.stringify(allowClear)}`, async () => { + const onClear = jest.fn(); + const onChange = jest.fn(); + const { container } = render( + , + ); + const input = container.querySelector('input'); + triggerFocus(input); + for (const key of '20240231') { + fireEvent.keyDown(input, { key }); + } + expect(input).toHaveValue('2024-02-31'); + expect(onChange).not.toHaveBeenCalled(); + expect(onClear).not.toHaveBeenCalled(); + + input.setSelectionRange(0, input.value.length); + fireEvent.change(input, { target: { value: '' } }); + + expect(onClear).toHaveBeenCalledTimes(allowClear === false ? 0 : 1); + expect(onChange).not.toHaveBeenCalled(); + if (allowClear !== false) { + expect(input).toHaveValue('YYYY-MM-DD'); + expect(isOpen()).toBeFalsy(); + triggerBlur(input); + await waitFakeTimer(); + expect(input).toHaveValue(''); + } + }); + }); + + it('does not manually clear when allowClear is false', async () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + const input = container.querySelector('input'); + + openPicker(container); + fireEvent.change(input, { target: { value: '' } }); + fireEvent.blur(input); + await waitFakeTimer(); + + expect(onChange).not.toHaveBeenCalled(); + expect(input).toHaveValue('2000-11-11'); + }); + // https://github.com/ant-design/ant-design/issues/49400 it('should not throw errow when input end year first', () => { const { container } = render(); diff --git a/tests/range.spec.tsx b/tests/range.spec.tsx index 7179d4a39..d09ebb531 100644 --- a/tests/range.spec.tsx +++ b/tests/range.spec.tsx @@ -247,6 +247,48 @@ describe('Picker.Range', () => { expect(onChange).not.toHaveBeenCalled(); }); + it('clears the range when an input value is manually removed', () => { + const onChange = jest.fn(); + const onClear = jest.fn(); + const { container } = render( + , + ); + + openPicker(container); + fireEvent.change(container.querySelectorAll('input')[0], { target: { value: '' } }); + + expect(onChange).toHaveBeenCalledWith(null, null); + expect(onClear).toHaveBeenCalledTimes(1); + matchValues(container, '', ''); + expect(isOpen()).toBeFalsy(); + }); + + it('keeps the other range value when the cleared field allows empty', () => { + const onChange = jest.fn(); + const onClear = jest.fn(); + const end = getDay('1990-09-23'); + const { container } = render( + , + ); + + openPicker(container); + fireEvent.change(container.querySelectorAll('input')[0], { target: { value: '' } }); + + expect(onChange).toHaveBeenCalledWith([null, end], ['', '1990-09-23']); + expect(onClear).toHaveBeenCalledTimes(1); + matchValues(container, '', '1990-09-23'); + expect(isOpen()).toBeFalsy(); + }); + describe('disabled', () => { it('should no panel open with disabled', () => { const { baseElement } = render();