Skip to content
13 changes: 10 additions & 3 deletions src/PickerInput/Selector/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,18 @@ const Input = React.forwardRef<InputRef, InputProps>((props, ref) => {

// Directly trigger `onChange` if `format` is empty
const onInternalChange: React.ChangeEventHandler<HTMLInputElement> = (event) => {
const text = event.target.value;

// Handle manual clear only when clearIcon is present (allowClear was enabled)
// If clearIcon is not set, reset back to previous valid date instead
if (text === '' && value !== '' && clearIcon) {
onChange('');
setInputValue('');
return;
}

// Hack `onChange` with format to do nothing
if (!format) {
const text = event.target.value;

onModify(text);
setInputValue(text);
onChange(text);
Expand Down Expand Up @@ -267,7 +275,6 @@ const Input = React.forwardRef<InputRef, InputProps>((props, ref) => {
nextCellText = '';
nextFillText = cellFormat;
break;

// =============== Arrows ===============
// Left key
case 'ArrowLeft':
Expand Down
10 changes: 8 additions & 2 deletions src/PickerInput/Selector/SingleSelector/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,14 @@ function SingleSelector<DateType extends object = any>(
const rootProps = useRootProps(restProps);

// ======================== Change ========================
const onSingleChange = (date: DateType) => {
onChange([date]);
const onSingleChange = (date: DateType | null) => {
if (date === null && clearIcon) {
// Only allow manual clear when clearIcon is present (allowClear was enabled)
onClear?.();
} else if (date !== null) {
onChange([date]);
}
// If date is null but clearIcon is not set, do nothing - let it reset to previous value
};

const onMultipleRemove = (date: DateType) => {
Expand Down
8 changes: 7 additions & 1 deletion src/PickerInput/Selector/hooks/useInputProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,14 @@ export default function useInputProps<DateType extends object = any>(
return;
}

// Handle intentional clearing: when text is empty, trigger onChange with null
if (text === '') {
onInvalid(false, index); // Reset invalid state before clearing the value
onChange(null, index);
return;
}

// Tell outer that the value typed is invalid.
// If text is empty, it means valid.
onInvalid(!!text, index);
},
onHelp: () => {
Expand Down
304 changes: 304 additions & 0 deletions tests/manual-clear.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
import { fireEvent, render } from '@testing-library/react';
import React from 'react';
import { Picker, RangePicker } from '../src';
import dayGenerateConfig from '../src/generate/dayjs';
import enUS from '../src/locale/en_US';
import { getDay, openPicker, waitFakeTimer } from './util/commonUtil';

describe('Picker.ManualClear', () => {
beforeEach(() => {
jest.useFakeTimers().setSystemTime(getDay('1990-09-03 00:00:00').valueOf());
});

afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
});

describe('Single Picker', () => {
it('should trigger onChange when manually clearing input', async () => {
const onChange = jest.fn();
const { container } = render(
<Picker
generateConfig={dayGenerateConfig}
value={getDay('2023-08-01')}
onChange={onChange}
locale={enUS}
allowClear
/>,
);

const input = container.querySelector('input') as HTMLInputElement;

openPicker(container);
fireEvent.change(input, { target: { value: '' } });

await waitFakeTimer();

expect(onChange).toHaveBeenCalledWith(null, null);
});

it('should NOT clear when allowClear is disabled - reset to previous value', async () => {
const onChange = jest.fn();
const { container } = render(
<Picker
generateConfig={dayGenerateConfig}
value={getDay('2023-08-01')}
onChange={onChange}
locale={enUS}
allowClear={false}
/>,
);

const input = container.querySelector('input') as HTMLInputElement;

expect(input.value).toBe('2023-08-01');

openPicker(container);
fireEvent.change(input, { target: { value: '' } });
fireEvent.blur(input);

await waitFakeTimer();

expect(onChange).not.toHaveBeenCalled();
expect(input.value).toBe('2023-08-01');
});

it('should reset invalid partial input on blur without triggering onChange', async () => {
const onChange = jest.fn();
const { container } = render(
<Picker
generateConfig={dayGenerateConfig}
value={getDay('2023-08-01')}
onChange={onChange}
locale={enUS}
format="YYYY-MM-DD"
/>,
);
const input = container.querySelector('input') as HTMLInputElement;
openPicker(container);
fireEvent.change(input, { target: { value: '2023-08' } });
const initialOnChangeCallCount = onChange.mock.calls.length;
fireEvent.blur(input);
await waitFakeTimer();
expect(onChange.mock.calls.length).toBe(initialOnChangeCallCount);
expect(input.value).toBe('2023-08-01');
});

it('should work with different picker modes', async () => {
const onChange = jest.fn();
const { container } = render(
<Picker
generateConfig={dayGenerateConfig}
value={getDay('2023-08-01')}
onChange={onChange}
locale={enUS}
picker="month"
allowClear
/>,
);

const input = container.querySelector('input') as HTMLInputElement;

openPicker(container);
fireEvent.change(input, { target: { value: '' } });

await waitFakeTimer();

expect(onChange).toHaveBeenCalledWith(null, null);
});

it('should clear input value when manually clearing', async () => {
const onChange = jest.fn();
const { container } = render(
<Picker
generateConfig={dayGenerateConfig}
value={getDay('2023-08-01')}
onChange={onChange}
locale={enUS}
allowClear
/>,
);

const input = container.querySelector('input') as HTMLInputElement;

expect(input.value).toBe('2023-08-01');

openPicker(container);
fireEvent.change(input, { target: { value: '' } });

await waitFakeTimer();

expect(input.value).toBe('');
});

it('should clear formatted input with mask format', async () => {
const onChange = jest.fn();
const { container } = render(
<Picker
generateConfig={dayGenerateConfig}
value={getDay('2023-08-01')}
onChange={onChange}
locale={enUS}
format={{ type: 'mask', format: 'YYYY-MM-DD' }}
allowClear
/>,
);

const input = container.querySelector('input') as HTMLInputElement;

openPicker(container);
fireEvent.change(input, { target: { value: '' } });

await waitFakeTimer();

expect(onChange).toHaveBeenCalledWith(null, null);
expect(input.value).toBe('');
});
});

describe('Range Picker', () => {
it('should clear start input value when manually clearing', async () => {
const onChange = jest.fn();
const { container } = render(
<RangePicker
generateConfig={dayGenerateConfig}
value={[getDay('2023-08-01'), getDay('2023-08-15')]}
onChange={onChange}
locale={enUS}
needConfirm={false}
allowClear
/>,
);

const startInput = container.querySelectorAll('input')[0] as HTMLInputElement;

openPicker(container, 0);
fireEvent.change(startInput, { target: { value: '' } });
fireEvent.blur(startInput);

await waitFakeTimer();

expect(startInput.value).toBe('');
});

it('should clear end input value when manually clearing', async () => {
const onChange = jest.fn();
const { container } = render(
<RangePicker
generateConfig={dayGenerateConfig}
value={[getDay('2023-08-01'), getDay('2023-08-15')]}
onChange={onChange}
locale={enUS}
needConfirm={false}
allowClear
/>,
);

const endInput = container.querySelectorAll('input')[1] as HTMLInputElement;

openPicker(container, 1);
fireEvent.change(endInput, { target: { value: '' } });
fireEvent.blur(endInput);

await waitFakeTimer();

expect(endInput.value).toBe('');
});

it('should clear both input values when manually clearing', async () => {
const onChange = jest.fn();
const { container } = render(
<RangePicker
generateConfig={dayGenerateConfig}
value={[getDay('2023-08-01'), getDay('2023-08-15')]}
onChange={onChange}
locale={enUS}
needConfirm={false}
allowClear
/>,
);

const startInput = container.querySelectorAll('input')[0] as HTMLInputElement;
const endInput = container.querySelectorAll('input')[1] as HTMLInputElement;

openPicker(container, 0);
fireEvent.change(startInput, { target: { value: '' } });
fireEvent.blur(startInput);
await waitFakeTimer();

openPicker(container, 1);
fireEvent.change(endInput, { target: { value: '' } });
fireEvent.blur(endInput);
await waitFakeTimer();

expect(startInput.value).toBe('');
expect(endInput.value).toBe('');
});

it('should clear input values when manually clearing', async () => {
const onChange = jest.fn();
const { container } = render(
<RangePicker
generateConfig={dayGenerateConfig}
value={[getDay('2023-08-01'), getDay('2023-08-15')]}
onChange={onChange}
locale={enUS}
allowClear
/>,
);

const startInput = container.querySelectorAll('input')[0] as HTMLInputElement;

expect(startInput.value).toBe('2023-08-01');

openPicker(container, 0);
fireEvent.change(startInput, { target: { value: '' } });

await waitFakeTimer();

expect(startInput.value).toBe('');
});
});

describe('Comparison with clear button', () => {
it('manual clear should behave the same as clear button for Picker', async () => {
const onChangeManual = jest.fn();
const onChangeClear = jest.fn();

const { container: container1 } = render(
<Picker
generateConfig={dayGenerateConfig}
value={getDay('2023-08-01')}
onChange={onChangeManual}
locale={enUS}
allowClear
/>,
);

const input1 = container1.querySelector('input') as HTMLInputElement;
openPicker(container1);
fireEvent.change(input1, { target: { value: '' } });
await waitFakeTimer();

const { container: container2 } = render(
<Picker
generateConfig={dayGenerateConfig}
value={getDay('2023-08-01')}
onChange={onChangeClear}
locale={enUS}
allowClear
/>,
);

const clearBtn = container2.querySelector('.rc-picker-clear');
fireEvent.mouseDown(clearBtn);
fireEvent.mouseUp(clearBtn);
fireEvent.click(clearBtn);
await waitFakeTimer();

expect(onChangeManual).toHaveBeenCalledWith(null, null);
expect(onChangeClear).toHaveBeenCalledWith(null, null);
});
});
});