From a9cb0bb18df166f503d953ad91e82e54227b3412 Mon Sep 17 00:00:00 2001 From: YeYiChen <80522109+Ye-YiChen@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:09:35 +0800 Subject: [PATCH 1/4] fix(Picker): discard the value rejected on popup close A field submitted by closing the popup kept its temporary calendar value when the submit was rejected by disabledDate, so the selector displayed a value the committed value never accepted. Blur and Enter also behaved differently for the same input, since Enter's close event arrives one step later and lands on the `currentIndex === null` branch. Let `flushSubmit` report whether the flushed value was accepted, and reset the temporary value inside `submitField` when it was rejected. Consumers that explicitly ask for the typed text to survive blur (`preserveInvalidOnBlur`) keep the previous behaviour. --- src/PickerInput/RangePicker.tsx | 6 +- src/PickerInput/SinglePicker.tsx | 5 +- src/PickerInput/hooks/useRangeValue.ts | 10 +- src/PickerInput/hooks/useRangeValueChange.ts | 22 ++++- tests/blur.spec.tsx | 97 ++++++++++++++++++++ 5 files changed, 133 insertions(+), 7 deletions(-) diff --git a/src/PickerInput/RangePicker.tsx b/src/PickerInput/RangePicker.tsx index 6c6f83496..ee2433619 100644 --- a/src/PickerInput/RangePicker.tsx +++ b/src/PickerInput/RangePicker.tsx @@ -217,6 +217,7 @@ function RangePicker( // Format inputReadOnly, + preserveInvalidOnBlur, suffix, @@ -317,11 +318,13 @@ function RangePicker( }); const flushFieldSubmit = useEvent((index: number, needTriggerChange: boolean) => { - flushSubmit(index, needTriggerChange); + const accepted = flushSubmit(index, needTriggerChange); if (needTriggerChange) { triggerOpen(false, { force: true }); } + + return accepted; }); const enabledFieldCount = disabled.filter((fieldDisabled) => !fieldDisabled).length; @@ -340,6 +343,7 @@ function RangePicker( triggerFieldCalendarChange, flushFieldSubmit, resetValue, + preserveInvalidOnBlur, ); useFocusLock(rangeValueIndex, forceFocus, selectorRef, popupRef, triggerOpen); diff --git a/src/PickerInput/SinglePicker.tsx b/src/PickerInput/SinglePicker.tsx index ab41dcf4b..5fea33253 100644 --- a/src/PickerInput/SinglePicker.tsx +++ b/src/PickerInput/SinglePicker.tsx @@ -180,6 +180,7 @@ function Picker( // Format inputReadOnly, + preserveInvalidOnBlur, suffix, removeIcon, @@ -324,8 +325,9 @@ function Picker( const flushFieldSubmit = useEvent((_index: number, needTriggerChange: boolean) => { if (needTriggerChange) { - triggerSubmitChange(getCalendarValue()); + const accepted = triggerSubmitChange(getCalendarValue()); triggerOpen(false, { force: true }); + return accepted; } }); @@ -343,6 +345,7 @@ function Picker( triggerFieldCalendarChange, flushFieldSubmit, resetFieldValue, + preserveInvalidOnBlur, ); // Finalize the current interaction only after the popup is actually closed. diff --git a/src/PickerInput/hooks/useRangeValue.ts b/src/PickerInput/hooks/useRangeValue.ts index 5de85f5c3..02b225c12 100644 --- a/src/PickerInput/hooks/useRangeValue.ts +++ b/src/PickerInput/hooks/useRangeValue.ts @@ -174,8 +174,12 @@ export default function useRangeValue boolean, ): [ - /** Trigger `onChange` by check `disabledDate` */ - flushSubmit: (index: number, needTriggerChange: boolean) => void, + /** + * Trigger `onChange` by check `disabledDate` + * Returns `false` when the flushed value is rejected. + * 按 `disabledDate` 校验后触发 `onChange`;被拒绝时返回 `false`。 + */ + flushSubmit: (index: number, needTriggerChange: boolean) => boolean | void, /** Trigger `onChange` directly without check `disabledDate` */ triggerSubmitChange: (value: ValueType) => boolean, /** Reset calendar and submit values back to the committed value */ @@ -300,7 +304,7 @@ export default function useRangeValue = (index: number, value: FieldValu /** * Flush one field and optionally emit the final change. + * Returns `false` when the flushed value is rejected by the business + * validation (`disabledDate` / order / empty rules). * 提交一个 field,并按需触发最终 change。 + * 返回 `false` 表示提交的值未通过业务校验(`disabledDate` / 顺序 / 空值规则)。 */ -export type FlushSubmit = (index: number, needTriggerChange: boolean) => void; +export type FlushSubmit = (index: number, needTriggerChange: boolean) => boolean | void; /** * Reset one field, or all fields when index is omitted. @@ -147,6 +150,14 @@ export default function useRangeValueChange( triggerCalendarChange: TriggerCalendarChange, flushSubmit: FlushSubmit, resetValue: ResetValue, + /** + * Keep the temporary value when a submit is rejected instead of discarding it. + * Consumers that promise "do not modify what the user typed on blur" + * (`preserveInvalidOnBlur`) pass `true` here. + * 提交被拒绝时保留临时值而不是丢弃它。明确承诺"blur 后不修改用户输入"的 + * 调用方(`preserveInvalidOnBlur`)传 `true`。 + */ + preserveRejectedValue = false, ): UseRangeValueChangeReturn { // ============================= State ============================= @@ -226,7 +237,14 @@ export default function useRangeValueChange( // Trigger final change after every field has participated once. // 所有 field 都参与过一次后,触发最终 change。 const allFieldsTriggered = triggeredFieldsRef.current.length >= fieldCount; - flushSubmit(index, allFieldsTriggered); + const accepted = flushSubmit(index, allFieldsTriggered); + + // A rejected submit must not keep the temporary value, otherwise the + // selector shows a value the committed value never accepted. + // 提交被拒绝时不能保留临时值,否则 selector 会显示已提交值从未接受的内容。 + if (accepted === false && !preserveRejectedValue) { + resetValue(); + } if (allFieldsTriggered) { reset(); diff --git a/tests/blur.spec.tsx b/tests/blur.spec.tsx index 6bf027a42..b9c3f3751 100644 --- a/tests/blur.spec.tsx +++ b/tests/blur.spec.tsx @@ -4,6 +4,7 @@ import { closePicker, DayPicker, DayRangePicker, + getDay, getMoment, openPicker, waitFakeTimer, @@ -65,3 +66,99 @@ describe('Picker.ChangeOnBlur', () => { expect(endInputEle).toHaveValue('no valid 2'); }); }); + +describe('Picker.DiscardRejectedValue', () => { + beforeEach(() => { + jest.useFakeTimers().setSystemTime(getMoment('1990-09-03 00:00:00').valueOf()); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('revert the value rejected by disabledDate when popup closed', async () => { + const onChange = jest.fn(); + const { container } = render( + date.isBefore(getDay('2026-06-01'))} + onChange={onChange} + />, + ); + + const inputEle = container.querySelector('input'); + + openPicker(container); + fireEvent.change(inputEle, { + target: { + value: '2026-03-15', + }, + }); + expect(inputEle).toHaveValue('2026-03-15'); + + // Close from outside instead of pressing Enter + closePicker(container); + await waitFakeTimer(); + + // The rejected value should be discarded + expect(inputEle).toHaveValue('2026-06-15'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('keep the rejected value when preserveInvalidOnBlur is set', async () => { + const onChange = jest.fn(); + const { container } = render( + date.isBefore(getDay('2026-06-01'))} + onChange={onChange} + preserveInvalidOnBlur + />, + ); + + const inputEle = container.querySelector('input'); + + openPicker(container); + fireEvent.change(inputEle, { + target: { + value: '2026-03-15', + }, + }); + closePicker(container); + await waitFakeTimer(); + + // `preserveInvalidOnBlur` promises the typed text is not modified on blur + expect(inputEle).toHaveValue('2026-03-15'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('keep the value accepted by disabledDate', async () => { + const onChange = jest.fn(); + const { container } = render( + date.isBefore(getDay('2026-06-01'))} + onChange={onChange} + />, + ); + + const inputEle = container.querySelector('input'); + + openPicker(container); + fireEvent.change(inputEle, { + target: { + value: '2026-06-20', + }, + }); + + closePicker(container); + await waitFakeTimer(); + + expect(inputEle).toHaveValue('2026-06-20'); + expect(onChange).toHaveBeenCalledWith(expect.anything(), '2026-06-20'); + }); +}); From 892e67067ad926c0a44b1d927a96e3074605cdd1 Mon Sep 17 00:00:00 2001 From: YeYiChen <80522109+Ye-YiChen@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:17:02 +0800 Subject: [PATCH 2/4] fix: discard the rejected value regardless of preserveInvalidOnBlur `preserveInvalidOnBlur` only covers text that cannot be parsed, so a date rejected by `disabledDate` is discarded on blur either way. Drop the exception that was added for it and pin the behaviour with a test. --- src/PickerInput/RangePicker.tsx | 2 -- src/PickerInput/SinglePicker.tsx | 2 -- src/PickerInput/hooks/useRangeValueChange.ts | 10 +--------- tests/blur.spec.tsx | 7 ++++--- 4 files changed, 5 insertions(+), 16 deletions(-) diff --git a/src/PickerInput/RangePicker.tsx b/src/PickerInput/RangePicker.tsx index ee2433619..0bf9e6b8a 100644 --- a/src/PickerInput/RangePicker.tsx +++ b/src/PickerInput/RangePicker.tsx @@ -217,7 +217,6 @@ function RangePicker( // Format inputReadOnly, - preserveInvalidOnBlur, suffix, @@ -343,7 +342,6 @@ function RangePicker( triggerFieldCalendarChange, flushFieldSubmit, resetValue, - preserveInvalidOnBlur, ); useFocusLock(rangeValueIndex, forceFocus, selectorRef, popupRef, triggerOpen); diff --git a/src/PickerInput/SinglePicker.tsx b/src/PickerInput/SinglePicker.tsx index 5fea33253..833990f82 100644 --- a/src/PickerInput/SinglePicker.tsx +++ b/src/PickerInput/SinglePicker.tsx @@ -180,7 +180,6 @@ function Picker( // Format inputReadOnly, - preserveInvalidOnBlur, suffix, removeIcon, @@ -345,7 +344,6 @@ function Picker( triggerFieldCalendarChange, flushFieldSubmit, resetFieldValue, - preserveInvalidOnBlur, ); // Finalize the current interaction only after the popup is actually closed. diff --git a/src/PickerInput/hooks/useRangeValueChange.ts b/src/PickerInput/hooks/useRangeValueChange.ts index a5af12acf..620ed752f 100644 --- a/src/PickerInput/hooks/useRangeValueChange.ts +++ b/src/PickerInput/hooks/useRangeValueChange.ts @@ -150,14 +150,6 @@ export default function useRangeValueChange( triggerCalendarChange: TriggerCalendarChange, flushSubmit: FlushSubmit, resetValue: ResetValue, - /** - * Keep the temporary value when a submit is rejected instead of discarding it. - * Consumers that promise "do not modify what the user typed on blur" - * (`preserveInvalidOnBlur`) pass `true` here. - * 提交被拒绝时保留临时值而不是丢弃它。明确承诺"blur 后不修改用户输入"的 - * 调用方(`preserveInvalidOnBlur`)传 `true`。 - */ - preserveRejectedValue = false, ): UseRangeValueChangeReturn { // ============================= State ============================= @@ -242,7 +234,7 @@ export default function useRangeValueChange( // A rejected submit must not keep the temporary value, otherwise the // selector shows a value the committed value never accepted. // 提交被拒绝时不能保留临时值,否则 selector 会显示已提交值从未接受的内容。 - if (accepted === false && !preserveRejectedValue) { + if (accepted === false) { resetValue(); } diff --git a/tests/blur.spec.tsx b/tests/blur.spec.tsx index b9c3f3751..7fb146dc9 100644 --- a/tests/blur.spec.tsx +++ b/tests/blur.spec.tsx @@ -107,7 +107,7 @@ describe('Picker.DiscardRejectedValue', () => { expect(onChange).not.toHaveBeenCalled(); }); - it('keep the rejected value when preserveInvalidOnBlur is set', async () => { + it('still discards the rejected value when preserveInvalidOnBlur is set', async () => { const onChange = jest.fn(); const { container } = render( { closePicker(container); await waitFakeTimer(); - // `preserveInvalidOnBlur` promises the typed text is not modified on blur - expect(inputEle).toHaveValue('2026-03-15'); + // The prop only covers text that cannot be parsed; a date rejected by + // `disabledDate` is discarded on blur either way. + expect(inputEle).toHaveValue('2026-06-15'); expect(onChange).not.toHaveBeenCalled(); }); From 19fb55828bda505b0b33f3d01d9905360b5901f8 Mon Sep 17 00:00:00 2001 From: YeYiChen <80522109+Ye-YiChen@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:28:49 +0800 Subject: [PATCH 3/4] test: cover a rejected range on popup close `RangePicker.flushFieldSubmit` forwards the submit result, but nothing exercised it, so dropping the forwarded value would restore the original bug without failing any test. --- tests/blur.spec.tsx | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/blur.spec.tsx b/tests/blur.spec.tsx index 7fb146dc9..2ac4e9063 100644 --- a/tests/blur.spec.tsx +++ b/tests/blur.spec.tsx @@ -162,4 +162,41 @@ describe('Picker.DiscardRejectedValue', () => { expect(inputEle).toHaveValue('2026-06-20'); expect(onChange).toHaveBeenCalledWith(expect.anything(), '2026-06-20'); }); + + it('reverts rejected values in RangePicker when popup closes', async () => { + const onChange = jest.fn(); + const { container } = render( + date.isBefore(getDay('2026-06-01'))} + onChange={onChange} + />, + ); + + const [startInputEle, endInputEle] = container.querySelectorAll('input'); + + // Both fields have to participate before the round is submitted. + openPicker(container); + fireEvent.change(startInputEle, { + target: { + value: '2026-03-15', + }, + }); + + openPicker(container, 1); + fireEvent.change(endInputEle, { + target: { + value: '2026-03-20', + }, + }); + + closePicker(container, 1); + await waitFakeTimer(); + + // The rejected range is discarded as a whole. + expect(startInputEle).toHaveValue('2026-06-15'); + expect(endInputEle).toHaveValue('2026-06-20'); + expect(onChange).not.toHaveBeenCalled(); + }); }); From 53e030f7cf0ffa3abb7ab2285a4490d46c183fb7 Mon Sep 17 00:00:00 2001 From: YeYiChen <80522109+Ye-YiChen@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:38:29 +0800 Subject: [PATCH 4/4] test: reject the start and the end separately in the range case Rejecting both temporary dates at once only proves that the range as a whole is discarded; running the rejected start and the rejected end separately covers each side of the check. --- tests/blur.spec.tsx | 78 ++++++++++++++++++++++++--------------------- 1 file changed, 42 insertions(+), 36 deletions(-) diff --git a/tests/blur.spec.tsx b/tests/blur.spec.tsx index 2ac4e9063..54bbfa024 100644 --- a/tests/blur.spec.tsx +++ b/tests/blur.spec.tsx @@ -163,40 +163,46 @@ describe('Picker.DiscardRejectedValue', () => { expect(onChange).toHaveBeenCalledWith(expect.anything(), '2026-06-20'); }); - it('reverts rejected values in RangePicker when popup closes', async () => { - const onChange = jest.fn(); - const { container } = render( - date.isBefore(getDay('2026-06-01'))} - onChange={onChange} - />, - ); - - const [startInputEle, endInputEle] = container.querySelectorAll('input'); - - // Both fields have to participate before the round is submitted. - openPicker(container); - fireEvent.change(startInputEle, { - target: { - value: '2026-03-15', - }, - }); - - openPicker(container, 1); - fireEvent.change(endInputEle, { - target: { - value: '2026-03-20', - }, - }); - - closePicker(container, 1); - await waitFakeTimer(); - - // The rejected range is discarded as a whole. - expect(startInputEle).toHaveValue('2026-06-15'); - expect(endInputEle).toHaveValue('2026-06-20'); - expect(onChange).not.toHaveBeenCalled(); - }); + it.each([ + { rejected: '2026-03-15', start: '2026-03-15', end: '2026-03-20' }, + { rejected: '2026-03-20', start: '2026-03-15', end: '2026-03-20' }, + ])( + 'reverts a range whose start or end is rejected on popup close ($rejected)', + async ({ rejected, start, end }) => { + const onChange = jest.fn(); + const { container } = render( + date.format('YYYY-MM-DD') === rejected} + onChange={onChange} + />, + ); + + const [startInputEle, endInputEle] = container.querySelectorAll('input'); + + // Both fields have to participate before the round is submitted. + openPicker(container); + fireEvent.change(startInputEle, { + target: { + value: start, + }, + }); + + openPicker(container, 1); + fireEvent.change(endInputEle, { + target: { + value: end, + }, + }); + + closePicker(container, 1); + await waitFakeTimer(); + + // The rejected range is discarded as a whole. + expect(startInputEle).toHaveValue('2026-06-15'); + expect(endInputEle).toHaveValue('2026-06-20'); + expect(onChange).not.toHaveBeenCalled(); + }, + ); });