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'); + }); +});