diff --git a/docs/demo/allowEmptySwitch.md b/docs/demo/allowEmptySwitch.md new file mode 100644 index 000000000..be774bb87 --- /dev/null +++ b/docs/demo/allowEmptySwitch.md @@ -0,0 +1,8 @@ +--- +title: allowEmptySwitch +nav: + title: Demo + path: /demo +--- + + diff --git a/docs/examples/allowEmptySwitch.tsx b/docs/examples/allowEmptySwitch.tsx new file mode 100644 index 000000000..c46b715c1 --- /dev/null +++ b/docs/examples/allowEmptySwitch.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import '../../assets/index.less'; +import RangePicker from '../../src/PickerInput/RangePicker'; + +import dayjs, { type Dayjs } from 'dayjs'; +import 'dayjs/locale/zh-cn'; +import dayjsGenerateConfig from '../../src/generate/dayjs'; +import zhCN from '../../src/locale/zh_CN'; + +dayjs.locale('zh-cn'); + +export default () => { + const [value, setValue] = React.useState<[Dayjs | null, Dayjs | null] | null>(null); + + return ( +
+

+ {'allowEmpty={[false, true]}'} +

+

+ Click start, then end — focus should switch. allowEmpty only gates submit, not + focus. +

+ +
+ ); +}; diff --git a/src/PickerInput/hooks/useRangeValueChange.ts b/src/PickerInput/hooks/useRangeValueChange.ts index 6fad8d25a..c29599499 100644 --- a/src/PickerInput/hooks/useRangeValueChange.ts +++ b/src/PickerInput/hooks/useRangeValueChange.ts @@ -23,7 +23,6 @@ export type RangeValueChangeAction = | 'switchNext' | 'finish' | 'abort' - | 'resetCurrent' | 'resetCurrentAndSwitchNext' | 'resetAll'; @@ -97,10 +96,12 @@ interface TriggeredField { * 没有当前 field 时,`popupClose` 解析为 `resetAll`;其余非撤销事件从 * 对应 field 开始新一轮交互。 * - `field-switch` advances exactly one field in circular order. `needConfirm` - * locks an unconfirmed non-empty field unless it allows empty; an allow-empty - * field is reset before advancing. + * locks an unconfirmed non-empty field unless it allows empty; an empty or + * allow-empty field is reset before advancing. `allowEmpty` does not block + * focusing another field when the current value is empty. * `field-switch` 只允许按循环顺序推进一个 field。`needConfirm` 会锁定未确认 - * 且非空的 field;允许空值时先重置再推进。 + * 且非空的 field;空值或允许空值时先重置再推进。当前值为空时,`allowEmpty` + * 不应阻止聚焦到另一个 field。 * - Other sources must target the current field. `input` and * `panel-intermediate` modify it; `remove` explicitly submits the removed * value even when the field does not allow empty. `panel-final` advances only @@ -131,8 +132,6 @@ interface TriggeredField { * resetting values. / 结束所有 field 均未修改的交互,不重置任何值。 * - `abort`: stop without changing any state. * 直接短路,不改变任何状态。 - * - `resetCurrent`: discard only the current field. - * 仅撤销当前 field。 * - `resetCurrentAndSwitchNext`: discard the current temporary value and * advance without submitting. Revisiting a field starts a new round. * 撤销当前临时值并直接推进,不触发提交;再次进入已访问 field 时开启新一轮。 @@ -271,31 +270,34 @@ export default function useRangeValueChange( return 'abort'; } - const nextFieldTriggered = triggeredFieldsRef.current.some( - (field) => field.index === nextIndex, - ); - if (needConfirm) { if (confirmedIndexRef.current === currentIndex) { return 'switchNext'; } - // An allowEmpty field may be left without confirmation. Discard any - // unconfirmed CalendarValue before moving to the next field. - // allowEmpty field 可以在未确认时离开;切换前需要丢弃未确认的 - // CalendarValue,再进入下一个 field。 - return allowEmpty[currentIndex] ? 'resetCurrentAndSwitchNext' : 'abort'; + // Only lock an unconfirmed non-empty field that does not allow empty. + // Empty fields must still allow focus switching; allowEmpty only gates + // whether an empty value can be submitted. + // 仅锁定未确认且非空、且不允许为空的 field。空值 field 仍可切换焦点; + // allowEmpty 只约束空值是否可提交。 + if (!currentEmpty && !allowEmpty[currentIndex]) { + return 'abort'; + } + + // Discard any unconfirmed / empty CalendarValue before advancing. + // 切换前丢弃未确认或空的 CalendarValue。 + return 'resetCurrentAndSwitchNext'; } if (canSwitch) { return 'switchNext'; } - // Revisiting the next field starts another circular round. Discard the - // invalid current field and finish the old round before entering it. - // 再次进入已触发的 next field 表示开始新一轮循环。进入前先丢弃当前 - // 无效 field,并结束旧的一轮。 - return nextFieldTriggered ? 'resetCurrentAndSwitchNext' : 'resetCurrent'; + // Empty field that does not allow empty: discard it and still honor the + // explicit focus switch. Revisiting the next field also starts a new round. + // 不允许为空的空 field:丢弃当前值,但仍响应显式焦点切换;再次进入 + // next field 时同样开启新一轮。 + return 'resetCurrentAndSwitchNext'; } if (index !== currentIndex) { @@ -449,16 +451,6 @@ export default function useRangeValueChange( reset(); break; - case 'resetCurrent': - resetValue(actionIndex); - if (confirmedIndexRef.current === actionIndex) { - confirmedIndexRef.current = null; - } - triggeredFieldsRef.current = triggeredFieldsRef.current.filter( - (field) => field.index !== actionIndex, - ); - break; - case 'resetCurrentAndSwitchNext': { resetValue(actionIndex); if (confirmedIndexRef.current === actionIndex) { diff --git a/tests/range.spec.tsx b/tests/range.spec.tsx index 43ebad195..4b70035a4 100644 --- a/tests/range.spec.tsx +++ b/tests/range.spec.tsx @@ -68,6 +68,92 @@ describe('useRangeValueChange', () => { expect(flushSubmit).not.toHaveBeenCalled(); expect(resetValue).not.toHaveBeenCalled(); }); + + it('should reset empty field and switch when allowEmpty is false', () => { + const triggerCalendarChange = jest.fn(); + const flushSubmit = jest.fn(); + const resetValue = jest.fn(); + const { result } = renderHook(() => + useRangeValueChange( + 2, + false, + [false, true], + () => [null, null], + triggerCalendarChange, + flushSubmit, + resetValue, + ), + ); + + act(() => { + result.current[4](0, 'field-switch'); + result.current[4](1, 'field-switch'); + }); + + // Empty + !allowEmpty still switches via resetCurrentAndSwitchNext. + // 空值且不允许为空时,仍通过 resetCurrentAndSwitchNext 切换。 + expect(resetValue).toHaveBeenCalledWith(0); + expect(flushSubmit).not.toHaveBeenCalled(); + expect(triggerCalendarChange).not.toHaveBeenCalled(); + expect(result.current[0]).toBe(1); + expect(result.current[3]).toEqual([0, 1]); + }); + + it('should reset empty unconfirmed field and switch under needConfirm', () => { + const triggerCalendarChange = jest.fn(); + const flushSubmit = jest.fn(); + const resetValue = jest.fn(); + const { result } = renderHook(() => + useRangeValueChange( + 2, + true, + [false, true], + () => [null, null], + triggerCalendarChange, + flushSubmit, + resetValue, + ), + ); + + act(() => { + result.current[4](0, 'field-switch'); + result.current[4](1, 'field-switch'); + }); + + expect(resetValue).toHaveBeenCalledWith(0); + expect(flushSubmit).not.toHaveBeenCalled(); + expect(result.current[0]).toBe(1); + expect(result.current[3]).toEqual([0, 1]); + }); + + it('should start a new round when switching back to a triggered empty field', () => { + const flushSubmit = jest.fn(); + const resetValue = jest.fn(); + const { result } = renderHook(() => + useRangeValueChange( + 2, + false, + [false, false], + () => [null, null], + jest.fn(), + flushSubmit, + resetValue, + ), + ); + + act(() => { + result.current[4](0, 'field-switch'); + result.current[4](1, 'field-switch'); + result.current[4](0, 'field-switch'); + }); + + // Revisiting start clears the old round, then records the new start field. + // 再次进入 start 会清空旧一轮记录,再记录新的 start field。 + expect(resetValue).toHaveBeenCalledWith(1); + expect(flushSubmit).not.toHaveBeenCalled(); + expect(result.current[0]).toBe(0); + expect(result.current[3]).toEqual([0]); + }); }); describe('Picker.Range', () => { @@ -2300,6 +2386,34 @@ describe('Picker.Range', () => { expect(container.querySelectorAll('.rc-picker-input')[0]).toHaveClass('rc-picker-input-active'); }); + it('should allow focusing end when start is empty with allowEmpty={[false, true]}', () => { + const { container } = render(); + const [startInput, endInput] = container.querySelectorAll('input'); + + openPicker(container, 0); + expect(startInput).toHaveFocus(); + expect(container.querySelectorAll('.rc-picker-input')[0]).toHaveClass('rc-picker-input-active'); + + openPicker(container, 1); + expect(endInput).toHaveFocus(); + expect(startInput).not.toHaveFocus(); + expect(container.querySelectorAll('.rc-picker-input')[1]).toHaveClass('rc-picker-input-active'); + }); + + it('should allow focusing either field when allowEmpty is unset and both are empty', () => { + const { container } = render(); + const [startInput, endInput] = container.querySelectorAll('input'); + + openPicker(container, 0); + openPicker(container, 1); + expect(endInput).toHaveFocus(); + expect(container.querySelectorAll('.rc-picker-input')[1]).toHaveClass('rc-picker-input-active'); + + openPicker(container, 0); + expect(startInput).toHaveFocus(); + expect(container.querySelectorAll('.rc-picker-input')[0]).toHaveClass('rc-picker-input-active'); + }); + // https://github.com/ant-design/ant-design/issues/57728 it('should not submit unconfirmed allowEmpty value on blur', async () => { const onChange = jest.fn();