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();