diff --git a/src/PickerInput/RangePicker.tsx b/src/PickerInput/RangePicker.tsx index 6c6f83496..0bf9e6b8a 100644 --- a/src/PickerInput/RangePicker.tsx +++ b/src/PickerInput/RangePicker.tsx @@ -317,11 +317,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; diff --git a/src/PickerInput/SinglePicker.tsx b/src/PickerInput/SinglePicker.tsx index ab41dcf4b..833990f82 100644 --- a/src/PickerInput/SinglePicker.tsx +++ b/src/PickerInput/SinglePicker.tsx @@ -324,8 +324,9 @@ function Picker( const flushFieldSubmit = useEvent((_index: number, needTriggerChange: boolean) => { if (needTriggerChange) { - triggerSubmitChange(getCalendarValue()); + const accepted = triggerSubmitChange(getCalendarValue()); triggerOpen(false, { force: true }); + return accepted; } }); diff --git a/src/PickerInput/hooks/useRangeValue.ts b/src/PickerInput/hooks/useRangeValue.ts index 5de85f5c3..877963452 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. A part submit only + * checks the flushed field. + */ + flushSubmit: (index: number, needTriggerChange: boolean) => boolean, /** Trigger `onChange` directly without check `disabledDate` */ triggerSubmitChange: (value: ValueType) => boolean, /** Reset calendar and submit values back to the committed value */ @@ -300,8 +304,20 @@ export default function useRangeValue { diff --git a/src/PickerInput/hooks/useRangeValueChange.ts b/src/PickerInput/hooks/useRangeValueChange.ts index 6fad8d25a..c2f405b84 100644 --- a/src/PickerInput/hooks/useRangeValueChange.ts +++ b/src/PickerInput/hooks/useRangeValueChange.ts @@ -21,7 +21,6 @@ export type RangeValueChangeAction = | 'modify' | 'submitCurrent' | 'switchNext' - | 'finish' | 'abort' | 'resetCurrent' | 'resetCurrentAndSwitchNext' @@ -42,9 +41,10 @@ export type TriggerCalendarChange = (index: number, value: FieldValu /** * Flush one field and optionally emit the final change. - * 提交一个 field,并按需触发最终 change。 + * Returns `false` when the flushed value is rejected by the business + * validation (`disabledDate` / order / empty rules). */ -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. @@ -111,13 +111,12 @@ interface TriggeredField { * `remove` 会明确提交删除后的值,即使 field 不允许为空;`panel-final` * 仅在无需确认时推进;`keyboard-submit-weak` 只做局部提交而不推进; * `keyboard-submit` 与 `confirm` 仅在有值或允许空值时推进。 - * - `popupClose` finishes an untouched interaction. Without confirmation it - * submits a valid field; with confirmation it submits only after every field - * has participated, otherwise it resets all temporary values. A modified - * allow-empty field is reset before the final submit. - * `popupClose` 会直接结束未修改的交互。无需确认时提交有效 field;需要确认时 - * 仅在所有 field 都参与过后提交,否则重置全部临时值。当前 field 已修改且 - * 允许为空时,会先重置当前值再完成提交。 + * - `popupClose` resets an untouched interaction, so a draft rejected earlier + * (e.g. by a field switch) does not survive the close. Without confirmation + * it submits a valid field and discards a rejected one; with confirmation it + * submits only after every field has participated, otherwise it resets all + * temporary values. A modified allow-empty field is reset before the final + * submit. * * Action execution / Action 执行: * @@ -127,8 +126,6 @@ interface TriggeredField { * 局部提交当前 field,但不推进。 * - `switchNext`: submit the current field and advance to the next field. * 提交当前 field 并推进到下一个 field。 - * - `finish`: end an interaction in which no field was modified without - * resetting values. / 结束所有 field 均未修改的交互,不重置任何值。 * - `abort`: stop without changing any state. * 直接短路,不改变任何状态。 * - `resetCurrent`: discard only the current field. @@ -218,21 +215,31 @@ export default function useRangeValueChange( // Flush the current field and report whether the whole round is complete. // Index and focus transitions are handled by the resolved action. - // 提交当前 field,并返回本轮是否已经完成。index 与焦点切换由解析后的 - // action 负责。 - const submitField = (index: number) => { + // `discardRejected` is only set when the submit ends the editing, such as a + // popup close. Other submits (e.g. a field switch) keep the pending values so + // that the user can continue correcting them. + const submitField = (index: number, discardRejected = false) => { recordTriggeredField(index); // 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 that ends the editing must not keep the temporary + // value, otherwise the selector shows a value the committed value never + // accepted. This also covers a part submit, so the round ends here. + const discarded = discardRejected && accepted === false; + if (discarded) { + resetValue(); + } - if (allFieldsTriggered) { + const roundFinished = allFieldsTriggered || discarded; + if (roundFinished) { reset(); } - return allFieldsTriggered; + return roundFinished; }; // ============================= Resolve =========================== @@ -306,7 +313,10 @@ export default function useRangeValueChange( const interactionModified = triggeredFieldsRef.current.some((field) => field.modified); if (!interactionModified) { - return 'finish'; + // Nothing was modified in this round, but a draft rejected by an + // earlier field switch may still be shown. No submit happens here, so + // resetting only restores such drafts and is a no-op otherwise. + return 'resetAll'; } if (needConfirm) { @@ -425,15 +435,15 @@ export default function useRangeValueChange( source === 'panel-final' || (source === 'popupClose' && !isLastInputRef.current); - const allFieldsTriggered = submitField(actionIndex); + const roundFinished = submitField(actionIndex, source === 'popupClose'); - if (allFieldsTriggered && source === 'field-switch') { + if (roundFinished && source === 'field-switch') { // The focus switch finishes the previous round and also starts a // new round from its target field. // 本次 focus 切换既结束上一轮,也以目标 field 开始新一轮。 setCurrentIndex(index); setForceFocus(false); - } else if (!allFieldsTriggered) { + } else if (!roundFinished) { const nextIndex = (actionIndex + 1) % fieldCount; setCurrentIndex(nextIndex); setForceFocus(forceFocus); @@ -445,10 +455,6 @@ export default function useRangeValueChange( break; } - case 'finish': - reset(); - break; - case 'resetCurrent': resetValue(actionIndex); if (confirmedIndexRef.current === actionIndex) { diff --git a/tests/blur.spec.tsx b/tests/blur.spec.tsx index 6bf027a42..4744e6ab6 100644 --- a/tests/blur.spec.tsx +++ b/tests/blur.spec.tsx @@ -4,8 +4,11 @@ import { closePicker, DayPicker, DayRangePicker, + getDay, getMoment, + isOpen, openPicker, + triggerFocus, waitFakeTimer, } from './util/commonUtil'; @@ -65,3 +68,305 @@ 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('still discards 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(); + + // 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(); + }); + + 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'); + }); + + 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(); + }, + ); + + it('keeps the valid pending end while switching back to correct an invalid start', async () => { + const onChange = jest.fn(); + const { container } = render( + date.isBefore(getDay('2026-06-01'), 'day')} + onChange={onChange} + />, + ); + + const [startInputEle, endInputEle] = container.querySelectorAll('input'); + + openPicker(container); + fireEvent.change(startInputEle, { + target: { + value: '2026-03-15', + }, + }); + + openPicker(container, 1); + fireEvent.change(endInputEle, { + target: { + value: '2026-06-25', + }, + }); + + // Switching fields keeps the popup open, so the interaction is not over + // and the pending values must stay for further correction. + triggerFocus(startInputEle); + await waitFakeTimer(); + + expect(isOpen()).toBeTruthy(); + expect(document.activeElement).toBe(startInputEle); + expect(onChange).not.toHaveBeenCalled(); + expect(startInputEle).toHaveValue('2026-03-15'); + expect(endInputEle).toHaveValue('2026-06-25'); + }); + + it('keeps the rejected value on Enter when the popup stays open', async () => { + const onChange = jest.fn(); + const { container } = render( + date.isBefore(getDay('2026-06-01'))} + onChange={onChange} + />, + ); + + const inputEle = container.querySelector('input'); + + fireEvent.focus(inputEle); + fireEvent.change(inputEle, { + target: { + value: '2026-03-15', + }, + }); + fireEvent.keyDown(inputEle, { key: 'Enter' }); + await waitFakeTimer(); + + // The popup is not closed, so the editing is not over yet. + expect(isOpen()).toBeTruthy(); + expect(inputEle).toHaveValue('2026-03-15'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('reverts a rejected start when only the start is edited before blur', async () => { + const onChange = jest.fn(); + const { container } = render( + date.isBefore(getDay('2026-06-01'), 'day')} + onChange={onChange} + />, + ); + + const [startInputEle, endInputEle] = container.querySelectorAll('input'); + + openPicker(container); + fireEvent.change(startInputEle, { + target: { + value: '2026-03-15', + }, + }); + + closePicker(container); + await waitFakeTimer(); + + expect(startInputEle).toHaveValue('2026-06-15'); + expect(endInputEle).toHaveValue('2026-06-20'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('reverts a draft rejected by a field switch when the popup closes untouched', async () => { + const onChange = jest.fn(); + const { container } = render( + date.isBefore(getDay('2026-06-01'), 'day')} + onChange={onChange} + />, + ); + + const [startInputEle, endInputEle] = container.querySelectorAll('input'); + + openPicker(container); + fireEvent.change(startInputEle, { + target: { + value: '2026-03-15', + }, + }); + + openPicker(container, 1); + fireEvent.change(endInputEle, { + target: { + value: '2026-06-25', + }, + }); + + // The rejected range is kept while switching back to correct it. + triggerFocus(startInputEle); + await waitFakeTimer(); + expect(startInputEle).toHaveValue('2026-03-15'); + + // Closing without any further change ends the editing. + closePicker(container); + await waitFakeTimer(); + + expect(startInputEle).toHaveValue('2026-06-15'); + expect(endInputEle).toHaveValue('2026-06-20'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('does not touch the value when the popup closes without any change', async () => { + const onChange = jest.fn(); + const onCalendarChange = jest.fn(); + const { container } = render( + date.isBefore(getDay('2026-06-01'), 'day')} + onChange={onChange} + onCalendarChange={onCalendarChange} + />, + ); + + const [startInputEle, endInputEle] = container.querySelectorAll('input'); + + openPicker(container); + closePicker(container); + await waitFakeTimer(); + + expect(startInputEle).toHaveValue('2026-06-15'); + expect(endInputEle).toHaveValue('2026-06-20'); + expect(onChange).not.toHaveBeenCalled(); + expect(onCalendarChange).not.toHaveBeenCalled(); + }); +});