Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/demo/allowEmptySwitch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
title: allowEmptySwitch
nav:
title: Demo
path: /demo
---

<code src="../examples/allowEmptySwitch.tsx"></code>
35 changes: 35 additions & 0 deletions docs/examples/allowEmptySwitch.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<h3>
<code>{'allowEmpty={[false, true]}'}</code>
</h3>
<p>
Click start, then end — focus should switch. <code>allowEmpty</code> only gates submit, not
focus.
</p>
<RangePicker
locale={zhCN}
generateConfig={dayjsGenerateConfig}
style={{ width: 400 }}
allowClear
allowEmpty={[false, true]}
value={value}
onChange={setValue}
/>
</div>
);
};
52 changes: 22 additions & 30 deletions src/PickerInput/hooks/useRangeValueChange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ export type RangeValueChangeAction =
| 'switchNext'
| 'finish'
| 'abort'
| 'resetCurrent'
| 'resetCurrentAndSwitchNext'
| 'resetAll';

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 时开启新一轮。
Expand Down Expand Up @@ -271,31 +270,34 @@ export default function useRangeValueChange<FieldValue = unknown>(
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) {
Expand Down Expand Up @@ -449,16 +451,6 @@ export default function useRangeValueChange<FieldValue = unknown>(
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) {
Expand Down
114 changes: 114 additions & 0 deletions tests/range.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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(<DayRangePicker allowEmpty={[false, true]} />);
const [startInput, endInput] = container.querySelectorAll<HTMLInputElement>('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(<DayRangePicker />);
const [startInput, endInput] = container.querySelectorAll<HTMLInputElement>('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();
Expand Down
Loading