From b38dfa0bd30f87c1eb8ea3f95cfa1da63677d2ce Mon Sep 17 00:00:00 2001 From: YeYiChen <80522109+Ye-YiChen@users.noreply.github.com> Date: Tue, 3 Nov 2026 20:11:05 +0800 Subject: [PATCH 1/2] fix: discard a value rejected on popup close --- src/PickerInput/RangePicker.tsx | 4 +- src/PickerInput/SinglePicker.tsx | 3 +- src/PickerInput/hooks/useRangeValue.ts | 6 +- src/PickerInput/hooks/useRangeValueChange.ts | 26 +- tests/blur.spec.tsx | 279 +++++++++++++++++++ 5 files changed, 304 insertions(+), 14 deletions(-) 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..128b3ceed 100644 --- a/src/PickerInput/hooks/useRangeValue.ts +++ b/src/PickerInput/hooks/useRangeValue.ts @@ -174,8 +174,8 @@ export default function useRangeValue boolean, ): [ - /** Trigger `onChange` by check `disabledDate` */ - flushSubmit: (index: number, needTriggerChange: boolean) => void, + /** Trigger `onChange` by check `disabledDate`, return `false` when rejected */ + 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 +300,7 @@ export default function useRangeValue = (index: number, value: FieldValu /** * Flush one field and optionally emit the final change. * 提交一个 field,并按需触发最终 change。 + * Returns `false` when the final change is rejected. */ -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. @@ -220,19 +221,26 @@ export default function useRangeValueChange( // Index and focus transitions are handled by the resolved action. // 提交当前 field,并返回本轮是否已经完成。index 与焦点切换由解析后的 // action 负责。 - const submitField = (index: number) => { + const submitField = (index: number, closing = false) => { recordTriggeredField(index); // Trigger final change after every field has participated once. // 所有 field 都参与过一次后,触发最终 change。 - const allFieldsTriggered = triggeredFieldsRef.current.length >= fieldCount; - flushSubmit(index, allFieldsTriggered); + const roundFinished = triggeredFieldsRef.current.length >= fieldCount; + const accepted = flushSubmit(index, roundFinished); + + // A submit rejected on close must not keep the temporary value, otherwise + // the selector shows a value that was never accepted. Other submits (e.g. a + // field switch) keep the draft for further correction. + if (closing && accepted === false) { + resetValue(); + } - if (allFieldsTriggered) { + if (roundFinished) { reset(); } - return allFieldsTriggered; + return roundFinished; }; // ============================= Resolve =========================== @@ -425,15 +433,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); diff --git a/tests/blur.spec.tsx b/tests/blur.spec.tsx index 6bf027a42..9c5f005c7 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,279 @@ 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).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][1]).toBe('2026-06-20'); + expect(onChange.mock.calls[0][0].format('YYYY-MM-DD')).toBe('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('reverts a confirmed but rejected start on popup close with needConfirm', 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', + }, + }); + + // Confirm the start, which moves the focus to the end. + fireEvent.keyDown(startInputEle, { key: 'Enter' }); + await waitFakeTimer(); + expect(document.activeElement).toBe(endInputEle); + + // Closing with an untouched end runs the final submit, which is rejected. + closePicker(container, 1); + await waitFakeTimer(); + + 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('reverts a rejected start when the popup closes right after a field switch', 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', + }, + }); + + triggerFocus(endInputEle); + await waitFakeTimer(); + + closePicker(container, 1); + await waitFakeTimer(); + + expect(startInputEle).toHaveValue('2026-06-15'); + expect(endInputEle).toHaveValue('2026-06-20'); + expect(onChange).not.toHaveBeenCalled(); + }); + + 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(); + }); +}); From ff4aaee9a12fec5153d744e3e192106a23d4a3be Mon Sep 17 00:00:00 2001 From: YeYiChen <80522109+Ye-YiChen@users.noreply.github.com> Date: Tue, 3 Nov 2026 20:11:06 +0800 Subject: [PATCH 2/2] fix: finish the edit when the popup closes --- src/PickerInput/hooks/useRangeValueChange.ts | 43 +++--- tests/blur.spec.tsx | 150 +++++++++++++++++++ 2 files changed, 168 insertions(+), 25 deletions(-) diff --git a/src/PickerInput/hooks/useRangeValueChange.ts b/src/PickerInput/hooks/useRangeValueChange.ts index bbcc286e9..22dead905 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' @@ -112,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. Without confirmation it + * submits the current field: right after typing it ends the editing with a + * final submit, after a panel operation it advances to the next 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. A submit rejected on close is discarded. * * Action execution / Action 执行: * @@ -128,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. @@ -226,7 +222,10 @@ export default function useRangeValueChange( // Trigger final change after every field has participated once. // 所有 field 都参与过一次后,触发最终 change。 - const roundFinished = triggeredFieldsRef.current.length >= fieldCount; + // Closing the popup right after typing also ends the editing, while a close + // after a panel operation moves on to the next field. + const roundFinished = + triggeredFieldsRef.current.length >= fieldCount || (closing && isLastInputRef.current); const accepted = flushSubmit(index, roundFinished); // A submit rejected on close must not keep the temporary value, otherwise @@ -257,11 +256,15 @@ export default function useRangeValueChange( return 'resetAll'; } + // Closing the popup without any modification in this round discards the + // drafts left behind, e.g. by a controlled value or a rejected field switch. + // No submit happens here, so nothing changes when no draft is left. + if (source === 'popupClose' && !triggeredFieldsRef.current.some((field) => field.modified)) { + return 'resetAll'; + } + if (currentIndex === null) { - // Closing the popup after the interaction has completed still needs to - // discard any temporary CalendarValue left by a controlled value. - // 一轮交互结束后关闭 popup,仍需清理受控值留下的临时 CalendarValue。 - return source === 'popupClose' ? 'resetAll' : 'abort'; + return 'abort'; } const currentValue = value === undefined ? getCalendarValue()[currentIndex] : value; @@ -311,12 +314,6 @@ export default function useRangeValueChange( } if (source === 'popupClose') { - const interactionModified = triggeredFieldsRef.current.some((field) => field.modified); - - if (!interactionModified) { - return 'finish'; - } - if (needConfirm) { const currentModified = triggeredFieldsRef.current.some( (field) => field.index === currentIndex && field.modified, @@ -453,10 +450,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 9c5f005c7..d33a71711 100644 --- a/tests/blur.spec.tsx +++ b/tests/blur.spec.tsx @@ -343,4 +343,154 @@ describe('Picker.DiscardRejectedValue', () => { 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('commits the edited start when only the start is edited before blur', async () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + + const [startInputEle, endInputEle] = container.querySelectorAll('input'); + + openPicker(container); + fireEvent.change(startInputEle, { + target: { + value: '2026-06-10', + }, + }); + + closePicker(container); + await waitFakeTimer(); + + expect(startInputEle).toHaveValue('2026-06-10'); + expect(endInputEle).toHaveValue('2026-06-20'); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][1]).toEqual(['2026-06-10', '2026-06-20']); + }); + + it('commits the reordered range when only the start is edited before blur', async () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + + const [startInputEle, endInputEle] = container.querySelectorAll('input'); + + openPicker(container); + fireEvent.change(startInputEle, { + target: { + value: '2026-06-25', + }, + }); + + closePicker(container); + await waitFakeTimer(); + + expect(startInputEle).toHaveValue('2026-06-20'); + expect(endInputEle).toHaveValue('2026-06-25'); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][1]).toEqual(['2026-06-20', '2026-06-25']); + }); + + 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(); + }); });