Skip to content
Open
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
6 changes: 5 additions & 1 deletion src/PickerInput/RangePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ function RangePicker<DateType extends object = any>(

// Format
inputReadOnly,
preserveInvalidOnBlur,

suffix,

Expand Down Expand Up @@ -317,11 +318,13 @@ function RangePicker<DateType extends object = any>(
});

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;
Expand All @@ -340,6 +343,7 @@ function RangePicker<DateType extends object = any>(
triggerFieldCalendarChange,
flushFieldSubmit,
resetValue,
preserveInvalidOnBlur,
);

useFocusLock(rangeValueIndex, forceFocus, selectorRef, popupRef, triggerOpen);
Expand Down
5 changes: 4 additions & 1 deletion src/PickerInput/SinglePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ function Picker<DateType extends object = any>(

// Format
inputReadOnly,
preserveInvalidOnBlur,

suffix,
removeIcon,
Expand Down Expand Up @@ -324,8 +325,9 @@ function Picker<DateType extends object = any>(

const flushFieldSubmit = useEvent((_index: number, needTriggerChange: boolean) => {
if (needTriggerChange) {
triggerSubmitChange(getCalendarValue());
const accepted = triggerSubmitChange(getCalendarValue());
triggerOpen(false, { force: true });
return accepted;
}
});

Expand All @@ -343,6 +345,7 @@ function Picker<DateType extends object = any>(
triggerFieldCalendarChange,
flushFieldSubmit,
resetFieldValue,
preserveInvalidOnBlur,
);

// Finalize the current interaction only after the popup is actually closed.
Expand Down
10 changes: 7 additions & 3 deletions src/PickerInput/hooks/useRangeValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,12 @@ export default function useRangeValue<ValueType extends DateType[], DateType ext
formatList: FormatType[],
isInvalidateDate: (date: DateType, info?: { from?: DateType; activeIndex: number }) => 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.
* 按 `disabledDate` 校验后触发 `onChange`;被拒绝时返回 `false`。
*/
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 */
Expand Down Expand Up @@ -300,7 +304,7 @@ export default function useRangeValue<ValueType extends DateType[], DateType ext
setSubmitValue(nextSubmitValue);

if (needTriggerChange) {
triggerSubmit();
return triggerSubmit();
}
});

Expand Down
22 changes: 20 additions & 2 deletions src/PickerInput/hooks/useRangeValueChange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,12 @@ export type TriggerCalendarChange<FieldValue> = (index: number, value: FieldValu

/**
* Flush one field and optionally emit the final change.
* Returns `false` when the flushed value is rejected by the business
* validation (`disabledDate` / order / empty rules).
* 提交一个 field,并按需触发最终 change。
* 返回 `false` 表示提交的值未通过业务校验(`disabledDate` / 顺序 / 空值规则)。
*/
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.
Expand Down Expand Up @@ -147,6 +150,14 @@ export default function useRangeValueChange<FieldValue = unknown>(
triggerCalendarChange: TriggerCalendarChange<FieldValue>,
flushSubmit: FlushSubmit,
resetValue: ResetValue,
/**
* Keep the temporary value when a submit is rejected instead of discarding it.
* Consumers that promise "do not modify what the user typed on blur"
* (`preserveInvalidOnBlur`) pass `true` here.
* 提交被拒绝时保留临时值而不是丢弃它。明确承诺"blur 后不修改用户输入"的
* 调用方(`preserveInvalidOnBlur`)传 `true`。
*/
preserveRejectedValue = false,
): UseRangeValueChangeReturn<FieldValue> {
// ============================= State =============================

Expand Down Expand Up @@ -226,7 +237,14 @@ export default function useRangeValueChange<FieldValue = unknown>(
// 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 must not keep the temporary value, otherwise the
// selector shows a value the committed value never accepted.
// 提交被拒绝时不能保留临时值,否则 selector 会显示已提交值从未接受的内容。
if (accepted === false && !preserveRejectedValue) {
resetValue();
}

if (allFieldsTriggered) {
reset();
Expand Down
97 changes: 97 additions & 0 deletions tests/blur.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
closePicker,
DayPicker,
DayRangePicker,
getDay,
getMoment,
openPicker,
waitFakeTimer,
Expand Down Expand Up @@ -65,3 +66,99 @@ 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(
<DayPicker
format="YYYY-MM-DD"
value={getDay('2026-06-15')}
disabledDate={(date) => 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('keep the rejected value when preserveInvalidOnBlur is set', async () => {
const onChange = jest.fn();
const { container } = render(
<DayPicker
format="YYYY-MM-DD"
value={getDay('2026-06-15')}
disabledDate={(date) => 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();

// `preserveInvalidOnBlur` promises the typed text is not modified on blur
expect(inputEle).toHaveValue('2026-03-15');
expect(onChange).not.toHaveBeenCalled();
});

it('keep the value accepted by disabledDate', async () => {
const onChange = jest.fn();
const { container } = render(
<DayPicker
format="YYYY-MM-DD"
value={getDay('2026-06-15')}
disabledDate={(date) => 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');
});
});