diff --git a/docs/examples/debug.tsx b/docs/examples/debug.tsx
index 9aadb22e5..d7499fe6c 100644
--- a/docs/examples/debug.tsx
+++ b/docs/examples/debug.tsx
@@ -1,110 +1,39 @@
import * as React from 'react';
import '../../assets/index.less';
-import type { Locale } from '../../src/interface';
import RangePicker from '../../src/PickerInput/RangePicker';
-import SinglePicker from '../../src/PickerInput/SinglePicker';
-import PickerPanel from '../../src/PickerPanel';
-import dayjs, { type Dayjs } from 'dayjs';
-import 'dayjs/locale/ar';
+import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
-import buddhistEra from 'dayjs/plugin/buddhistEra';
-import LocalizedFormat from 'dayjs/plugin/localizedFormat';
import dayjsGenerateConfig from '../../src/generate/dayjs';
-import dateFnsGenerateConfig from '../../src/generate/dateFns';
import zhCN from '../../src/locale/zh_CN';
dayjs.locale('zh-cn');
-// dayjs.locale('ar');
-dayjs.extend(buddhistEra);
-dayjs.extend(LocalizedFormat);
-
-console.clear();
-
-(window as any).dayjs = dayjs;
-
-const myLocale: Locale = {
- ...zhCN,
- // cellQuarterFormat: '第Q季度',
- // fieldYearFormat: 'BBBB',
- // cellYearFormat: 'BBBB',
- // yearFormat: 'BBBB',
- // cellDateFormat: '!d!',
-};
-
-const sharedLocale = {
- locale: myLocale,
- generateConfig: dayjsGenerateConfig,
-};
-
-const dateFnsSharedLocale = {
- locale: myLocale,
- generateConfig: dateFnsGenerateConfig,
-};
export default () => {
+ const [changeCount, setChangeCount] = React.useState(0);
+
return (
- {/*
console.error('>>>>>>>', val)}
- /> */}
- {
- // console.log('Date:', info);
- // return false;
- // }}
- // disabledTime={(date, range, info) => {
- // // console.log(`Time-${range}`, range, info);
- // const { from } = info;
- // if (from) {
- // console.log(
- // `Time-${range}`,
- // from.format('YYYY-MM-DD HH:mm:ss'),
- // date.format('YYYY-MM-DD HH:mm:ss'),
- // );
- // }
-
- // if (from && from.isSame(date, 'day')) {
- // return {
- // disabledHours: () => [from.hour()],
- // disabledMinutes: () => [0, 1, 2, 3],
- // disabledSeconds: () => [0, 1, 2, 3],
- // };
- // }
- // return {};
- // }}
- />
+ Issue #57728: showTime with allowEmpty
+
+ Select a start date without confirming, switch to the end field, switch back to the start
+ field, then click the input above. Both fields should reset and the change count should stay
+ at 0.
+
+ Change count: {changeCount}
- {/* {
- console.log('Time Single:', ...args);
- return {};
+ allowEmpty
+ onChange={() => {
+ setChangeCount((count) => count + 1);
}}
- /> */}
- {/* console.error('>>>>>>>', val)}
- /> */}
+ />
);
};
diff --git a/src/PickerInput/Popup/index.tsx b/src/PickerInput/Popup/index.tsx
index f02810390..cb89a9b77 100644
--- a/src/PickerInput/Popup/index.tsx
+++ b/src/PickerInput/Popup/index.tsx
@@ -20,9 +20,11 @@ export type PopupShowTimeConfig = Omit<
Pick, 'disabledTime'>;
export interface PopupProps
- extends Pick, 'onFocus' | 'onBlur'>,
+ extends
+ Pick, 'onFocus' | 'onBlur'>,
FooterProps,
PopupPanelProps {
+ containerRef?: React.Ref;
panelRender?: SharedPickerProps['panelRender'];
// Presets
@@ -52,6 +54,7 @@ export interface PopupProps(props: PopupProps) {
const {
+ containerRef,
panelRender,
internalMode,
picker,
@@ -216,6 +219,7 @@ export default function Popup(props: PopupProps(config: T | [T, T] | null | undefined, defaultConfig: T): [T, T] {
@@ -233,7 +238,8 @@ function RangePicker(
} = filledProps;
// ========================= Refs =========================
- const selectorRef = usePickerRef(ref);
+ const selectorRef = usePickerRef[0], RangeSelectorRef>(ref);
+ const popupRef = React.useRef(null);
// ======================= Semantic =======================
const [mergedClassNames, mergedStyles] = useSemantic(propClassNames, propStyles);
@@ -264,34 +270,75 @@ function RangePicker(
const calendarValue = getCalendarValue();
- // ======================== Active ========================
+ // ======================== Focus =========================
+ const isInternalPickerElement = useEvent((element: EventTarget | null) =>
+ isTargetInContainers(element, [selectorRef.current.nativeElement, popupRef.current]),
+ );
+
+ const [focused, onFieldFocus, onFieldBlur] = useFocusEvents(
+ isInternalPickerElement,
+ (index, event) => {
+ onFocus?.(event, {
+ range: getActiveRange(index),
+ });
+ },
+ (index, event) => {
+ triggerRangeValueChange(index, 'blur');
+ triggerOpen(false);
+
+ onBlur?.(event, {
+ range: getActiveRange(index),
+ });
+ },
+ );
+
+ // ======================== Value =========================
const [
+ /** Trigger `onChange` by check `disabledDate` */
+ flushSubmit,
+ /** Trigger `onChange` directly without check `disabledDate` */
+ triggerSubmitChange,
+ /** Reset uncommitted values */
+ resetValue,
+ ] = useRangeValue, DateType>(
+ filledProps,
+ mergedValue,
+ setInnerValue,
+ getCalendarValue,
+ triggerCalendarChange,
+ disabled,
+ formatList,
focused,
- triggerFocus,
- lastOperation,
- activeIndex,
- setActiveIndex,
- nextActiveIndex,
- activeIndexList,
- updateSubmitIndex,
- hasActiveSubmitValue,
- ] = useRangeActive(disabled, allowEmpty, mergedOpen);
-
- const onSharedFocus = (event: React.FocusEvent, index?: number) => {
- triggerFocus(true);
-
- onFocus?.(event, {
- range: getActiveRange(index ?? activeIndex),
- });
- };
+ mergedOpen,
+ isInvalidateDate,
+ // false,
+ );
- const onSharedBlur = (event: React.FocusEvent, index?: number) => {
- triggerFocus(false);
+ const triggerFieldCalendarChange = useEvent((index: number, date: DateType) => {
+ triggerCalendarChange(fillIndex(getCalendarValue(), index, date));
+ });
- onBlur?.(event, {
- range: getActiveRange(index ?? activeIndex),
- });
- };
+ const flushFieldSubmit = useEvent((index: number, needTriggerChange: boolean) => {
+ flushSubmit(index, needTriggerChange);
+
+ if (needTriggerChange) {
+ triggerOpen(false, { force: true });
+ }
+ });
+
+ const enabledFieldCount = disabled.filter((fieldDisabled) => !fieldDisabled).length;
+ const [rangeValueIndex, activeIndex, triggeredFields, triggerRangeValueChange] =
+ useRangeValueChange(
+ enabledFieldCount,
+ needConfirm,
+ allowEmpty,
+ getCalendarValue,
+ triggerFieldCalendarChange,
+ flushFieldSubmit,
+ resetValue,
+ );
+
+ useFocusLock(rangeValueIndex, selectorRef, popupRef, triggerOpen);
// ======================= ShowTime =======================
/** Used for Popup panel */
@@ -307,7 +354,7 @@ function RangePicker(
const proxyDisabledTime = disabledTime
? (date: DateType) => {
const range = getActiveRange(activeIndex);
- const fromDate = getFromDate(calendarValue, activeIndexList, activeIndex);
+ const fromDate = getFromDate(calendarValue, triggeredFields, activeIndex);
return disabledTime(date, range, {
from: fromDate,
});
@@ -315,7 +362,7 @@ function RangePicker(
: undefined;
return { ...showTime, disabledTime: proxyDisabledTime };
- }, [showTime, activeIndex, calendarValue, activeIndexList]);
+ }, [showTime, activeIndex, calendarValue, triggeredFields]);
// ========================= Mode =========================
const [modes, setModes] = useControlledState<[PanelMode, PanelMode]>([picker, picker], mode);
@@ -332,30 +379,12 @@ function RangePicker(
// ======================= Show Now =======================
const mergedShowNow = useShowNow(picker, mergedMode, showNow, showToday, true);
- // ======================== Value =========================
- const [
- /** Trigger `onChange` by check `disabledDate` */
- flushSubmit,
- /** Trigger `onChange` directly without check `disabledDate` */
- triggerSubmitChange,
- ] = useRangeValue, DateType>(
- filledProps,
- mergedValue,
- setInnerValue,
- getCalendarValue,
- triggerCalendarChange,
- disabled,
- formatList,
- focused,
- mergedOpen,
- isInvalidateDate,
- );
-
// ===================== DisabledDate =====================
const mergedDisabledDate = useRangeDisabledDate(
calendarValue,
disabled,
- activeIndexList,
+ activeIndex,
+ triggeredFields,
generateConfig,
locale,
disabledDate,
@@ -375,6 +404,7 @@ function RangePicker(
calendarValue,
modes,
mergedOpen,
+ focused,
activeIndex,
internalPicker,
multiplePanel,
@@ -418,25 +448,8 @@ function RangePicker(
* - Selector: enter key
* - Panel: OK button
*/
- const triggerPartConfirm = (date?: DateType, skipFocus?: boolean) => {
- let nextValue = calendarValue;
-
- if (date) {
- nextValue = fillCalendarValue(date, activeIndex);
- }
- updateSubmitIndex(activeIndex);
- // Get next focus index
- const nextIndex = nextActiveIndex(nextValue);
-
- // Change calendar value and tell flush it
- triggerCalendarChange(nextValue);
- flushSubmit(activeIndex, nextIndex === null);
-
- if (nextIndex === null) {
- triggerOpen(false, { force: true });
- } else if (!skipFocus) {
- selectorRef.current.focus({ index: nextIndex });
- }
+ const triggerPartConfirm = (date?: DateType, source: RangeValueChangeSource = 'confirm') => {
+ triggerRangeValueChange(activeIndex, source, date ?? undefined);
};
// ======================== Click =========================
@@ -509,7 +522,6 @@ function RangePicker(
const passed = triggerSubmitChange(nextValues);
if (passed) {
- lastOperation('preset-click');
triggerOpen(false, { force: true });
}
};
@@ -526,31 +538,22 @@ function RangePicker(
// >>> Focus
const onPanelFocus: React.FocusEventHandler = (event) => {
triggerOpen(true);
- onSharedFocus(event);
- };
-
- // >>> MouseDown
- const onPanelMouseDown: React.MouseEventHandler = () => {
- lastOperation('panel');
+ onFieldFocus(activeIndex, 'panel', event);
};
// >>> Calendar
const onPanelSelect: PickerPanelProps['onChange'] = (date: DateType) => {
- const clone: RangeValueType = fillIndex(calendarValue, activeIndex, date);
-
- // Only trigger calendar event but not update internal `calendarValue` state
- triggerCalendarChange(clone);
+ const panelFinished = !complexPicker && internalPicker === internalMode;
- // >>> Trigger next active if !needConfirm
- // Fully logic check `useRangeValue` hook
- if (!needConfirm && !complexPicker && internalPicker === internalMode) {
- triggerPartConfirm(date);
- }
+ triggerRangeValueChange(
+ activeIndex,
+ panelFinished ? 'panel-final' : 'panel-intermediate',
+ date,
+ );
};
// >>> Close
const onPopupClose = () => {
- // Close popup
triggerOpen(false);
};
@@ -592,6 +595,7 @@ function RangePicker(
// >>> Render
const panel = (
+ containerRef={popupRef}
// MISC
{...panelProps}
showNow={mergedShowNow}
@@ -604,8 +608,7 @@ function RangePicker(
disabledDate={mergedDisabledDate}
// Focus
onFocus={onPanelFocus}
- onBlur={onSharedBlur}
- onPanelMouseDown={onPanelMouseDown}
+ onBlur={(event) => onFieldBlur(activeIndex, 'panel', event)}
// Mode
picker={picker}
mode={mergedMode}
@@ -626,7 +629,7 @@ function RangePicker(
onHover={onPanelHover}
// Submit
needConfirm={needConfirm}
- onSubmit={triggerPartConfirm}
+ onSubmit={(date) => triggerPartConfirm(date, 'confirm')}
onOk={triggerOk}
// Preset
presets={presetList}
@@ -648,64 +651,34 @@ function RangePicker(
// ======================== Change ========================
const onSelectorChange = (date: DateType, index: number) => {
- const clone = fillCalendarValue(date, index);
-
- triggerCalendarChange(clone);
+ triggerRangeValueChange(index, 'input', date);
};
const onSelectorInputChange = () => {
- lastOperation('input');
+ triggerRangeValueChange(activeIndex, 'input');
};
// ======================= Selector =======================
const onSelectorFocus: SelectorProps['onFocus'] = (event, index) => {
- // Check if `needConfirm` but user not submit yet
- const activeListLen = activeIndexList.length;
- const lastActiveIndex = activeIndexList[activeListLen - 1];
- if (
- activeListLen &&
- lastActiveIndex !== index &&
- needConfirm &&
- // Not change index if is not filled
- !allowEmpty[lastActiveIndex] &&
- !hasActiveSubmitValue(lastActiveIndex) &&
- calendarValue[lastActiveIndex]
- ) {
- selectorRef.current.focus({ index: lastActiveIndex });
- return;
- }
-
- lastOperation('input');
+ triggerRangeValueChange(index, 'field-switch');
triggerOpen(true, {
inherit: true,
});
- // When click input to switch the field, it will not trigger close.
- // Which means it will lose the part confirm and we need fill back.
- // ref: https://github.com/ant-design/ant-design/issues/49512
- if (activeIndex !== index && mergedOpen && !needConfirm && complexPicker) {
- triggerPartConfirm(null, true);
- }
-
- setActiveIndex(index);
-
- onSharedFocus(event, index);
+ onFieldFocus(index, 'input', event);
};
const onSelectorBlur: SelectorProps['onBlur'] = (event, index) => {
- triggerOpen(false);
- if (!needConfirm && lastOperation() === 'input') {
- const nextIndex = nextActiveIndex(calendarValue);
- flushSubmit(activeIndex, nextIndex === null);
- }
-
- onSharedBlur(event, index);
+ onFieldBlur(index, 'input', event);
};
const onSelectorKeyDown: SelectorProps['onKeyDown'] = (event, preventDefault) => {
if (event.key === 'Tab') {
- triggerPartConfirm(null, true);
+ triggerPartConfirm(null, 'keyboard-submit');
+ } else if (event.key === 'Escape') {
+ triggerRangeValueChange(activeIndex, 'esc');
+ triggerOpen(false);
}
onKeyDown?.(event, preventDefault);
@@ -743,23 +716,6 @@ function RangePicker(
}
}, [mergedOpen, activeIndex, picker]);
- // >>> For complex picker, we need check if need to focus next one
- useLayoutEffect(() => {
- const lastOp = lastOperation();
-
- // Trade as confirm on field leave
- if (!mergedOpen && lastOp === 'input') {
- triggerOpen(false);
- triggerPartConfirm(null, true);
- }
-
- // Submit with complex picker
- if (!mergedOpen && complexPicker && !needConfirm && lastOp === 'panel') {
- triggerOpen(true);
- triggerPartConfirm();
- }
- }, [mergedOpen]);
-
// ====================== DevWarning ======================
if (process.env.NODE_ENV !== 'production') {
const isIndexEmpty = (index: number) => {
@@ -815,7 +771,7 @@ function RangePicker(
onFocus={onSelectorFocus}
onBlur={onSelectorBlur}
onKeyDown={onSelectorKeyDown}
- onSubmit={triggerPartConfirm}
+ onSubmit={() => triggerPartConfirm(null, 'keyboard-submit')}
// Change
value={hoverValues}
maskFormat={maskFormat}
diff --git a/src/PickerInput/Selector/RangeSelector.tsx b/src/PickerInput/Selector/RangeSelector.tsx
index 6cc28a74b..7208fbc72 100644
--- a/src/PickerInput/Selector/RangeSelector.tsx
+++ b/src/PickerInput/Selector/RangeSelector.tsx
@@ -17,6 +17,11 @@ export type SelectorIdType =
end?: string;
};
+export interface RangeSelectorRef extends RangePickerRef {
+ startInput: HTMLInputElement;
+ endInput: HTMLInputElement;
+}
+
export interface RangeSelectorProps extends SelectorProps {
id?: SelectorIdType;
@@ -49,7 +54,7 @@ export interface RangeSelectorProps extends SelectorProps(
props: RangeSelectorProps,
- ref: React.Ref,
+ ref: React.Ref,
) {
const {
id,
@@ -144,6 +149,8 @@ function RangeSelector(
React.useImperativeHandle(ref, () => ({
nativeElement: rootRef.current,
+ startInput: inputStartRef.current.inputElement,
+ endInput: inputEndRef.current.inputElement,
focus: (options) => {
if (typeof options === 'object') {
const { index = 0, ...rest } = options || {};
diff --git a/src/PickerInput/SinglePicker.tsx b/src/PickerInput/SinglePicker.tsx
index 6cdf6be3c..6a3523f0d 100644
--- a/src/PickerInput/SinglePicker.tsx
+++ b/src/PickerInput/SinglePicker.tsx
@@ -20,12 +20,13 @@ import PickerContext from './context';
import useCellRender from './hooks/useCellRender';
import useFieldsInvalidate from './hooks/useFieldsInvalidate';
import useFilledProps from './hooks/useFilledProps';
+import useFocusEvents, { isTargetInContainers } from './hooks/useFocusEvents';
import useOpen from './hooks/useOpen';
import usePickerRef from './hooks/usePickerRef';
import usePresets from './hooks/usePresets';
-import useRangeActive from './hooks/useRangeActive';
import useRangePickerValue from './hooks/useRangePickerValue';
import useRangeValue, { useInnerValue } from './hooks/useRangeValue';
+import useRangeValueChange, { type RangeValueChangeSource } from './hooks/useRangeValueChange';
import useShowNow from './hooks/useShowNow';
import Popup from './Popup';
import SingleSelector from './Selector/SingleSelector';
@@ -204,6 +205,7 @@ function Picker(
// ========================= Refs =========================
const selectorRef = usePickerRef(ref);
+ const popupRef = React.useRef(null);
// ========================= Util =========================
function pickerParam(values: T | T[]) {
@@ -253,21 +255,22 @@ function Picker(
const calendarValue = getCalendarValue();
- // ======================== Active ========================
- // In SinglePicker, we will always get `activeIndex` is 0.
- const [focused, triggerFocus, lastOperation, activeIndex] = useRangeActive([disabled]);
-
- const onSharedFocus = (event: React.FocusEvent) => {
- triggerFocus(true);
-
- onFocus?.(event, {});
- };
-
- const onSharedBlur = (event: React.FocusEvent) => {
- triggerFocus(false);
+ // ======================== Focus =========================
+ const isInternalPickerElement = useEvent((element: EventTarget | null) =>
+ isTargetInContainers(element, [selectorRef.current.nativeElement, popupRef.current]),
+ );
- onBlur?.(event, {});
- };
+ const [focused, onFieldFocus, onFieldBlur] = useFocusEvents(
+ isInternalPickerElement,
+ (_index, event) => {
+ onFocus?.(event, {});
+ },
+ (index, event) => {
+ triggerSingleValueChange(index, 'blur');
+ triggerOpen(false);
+ onBlur?.(event, {});
+ },
+ );
// ========================= Mode =========================
const [mergedMode, setMode] = useControlledState(picker, mode);
@@ -289,6 +292,8 @@ function Picker(
,
/** Trigger `onChange` directly without check `disabledDate` */
triggerSubmitChange,
+ /** Reset uncommitted values */
+ resetValue,
] = useRangeValue(
{
...filledProps,
@@ -305,6 +310,40 @@ function Picker(
isInvalidateDate,
);
+ // Treat the complete SinglePicker value list as one field value. This keeps
+ // multiple dates inside field `0` instead of exposing them as extra fields.
+ // 将 SinglePicker 的整组值视为一个 field value;multiple 日期仍属于
+ // field `0` 内部,不会被当成额外的 field。
+ const getFieldCalendarValue = useEvent(() => {
+ const values = getCalendarValue();
+ return [values.length ? values : null];
+ });
+
+ const triggerFieldCalendarChange = useEvent((_index: number, nextValues: DateType[]) => {
+ triggerCalendarChange(nextValues);
+ });
+
+ const flushFieldSubmit = useEvent((_index: number, needTriggerChange: boolean) => {
+ if (needTriggerChange) {
+ triggerSubmitChange(getCalendarValue());
+ triggerOpen(false, { force: true });
+ }
+ });
+
+ const resetFieldValue = useEvent(() => {
+ resetValue();
+ });
+
+ const [, activeIndex, , triggerSingleValueChange] = useRangeValueChange(
+ 1,
+ needConfirm,
+ [false],
+ getFieldCalendarValue,
+ triggerFieldCalendarChange,
+ flushFieldSubmit,
+ resetFieldValue,
+ );
+
// ======================= Validate =======================
const [submitInvalidates, onSelectorInvalid] = useFieldsInvalidate(
calendarValue,
@@ -335,6 +374,7 @@ function Picker(
calendarValue,
[mergedMode],
mergedOpen,
+ false,
activeIndex,
internalPicker,
false, // multiplePanel,
@@ -362,13 +402,11 @@ function Picker(
// ======================== Submit ========================
/**
- * Different with RangePicker, confirm should check `multiple` logic.
- * This will never provide `date` instead.
+ * Submit the complete value list stored in SinglePicker field `0`.
+ * 提交 SinglePicker field `0` 内保存的整组值。
*/
- const triggerConfirm = () => {
- triggerSubmitChange(getCalendarValue());
-
- triggerOpen(false, { force: true });
+ const triggerConfirm = (source: RangeValueChangeSource = 'confirm') => {
+ triggerSingleValueChange(0, source);
};
// ======================== Click =========================
@@ -457,12 +495,12 @@ function Picker(
// >>> Focus
const onPanelFocus: React.FocusEventHandler = (event) => {
triggerOpen(true);
- onSharedFocus(event);
+ onFieldFocus(0, 'panel', event);
};
// >>> Calendar
const onPanelSelect = (date: DateType) => {
- lastOperation('panel');
+ // lastOperation('panel');
// Not change values if multiple and current panel is to match with picker
if (multiple && internalMode !== picker) {
@@ -470,15 +508,9 @@ function Picker(
}
const nextValues = multiple ? toggleDates(getCalendarValue(), date) : [date];
+ const panelFinished = !complexPicker && internalPicker === internalMode;
- // Only trigger calendar event but not update internal `calendarValue` state
- triggerCalendarChange(nextValues);
-
- // >>> Trigger next active if !needConfirm
- // Fully logic check `useRangeValue` hook
- if (!needConfirm && !complexPicker && internalPicker === internalMode) {
- triggerConfirm();
- }
+ triggerSingleValueChange(0, panelFinished ? 'panel-final' : 'panel-intermediate', nextValues);
};
// >>> Close
@@ -514,6 +546,7 @@ function Picker(
// >>> Render
const panel = (
+ containerRef={popupRef}
// MISC
{...panelProps}
showNow={mergedShowNow}
@@ -522,7 +555,7 @@ function Picker(
disabledDate={disabledDate}
// Focus
onFocus={onPanelFocus}
- onBlur={onSharedBlur}
+ onBlur={(event) => onFieldBlur(0, 'panel', event)}
// Mode
picker={picker}
mode={mergedMode}
@@ -543,7 +576,7 @@ function Picker(
onHover={onPanelHover}
// Submit
needConfirm={needConfirm}
- onSubmit={triggerConfirm}
+ onSubmit={() => triggerConfirm('confirm')}
onOk={triggerOk}
// Preset
presets={presetList}
@@ -564,35 +597,34 @@ function Picker(
// ======================== Change ========================
const onSelectorChange = (date: DateType[]) => {
- triggerCalendarChange(date);
+ triggerSingleValueChange(0, 'input', date);
};
const onSelectorInputChange = () => {
- lastOperation('input');
+ triggerSingleValueChange(0, 'input');
};
// ======================= Selector =======================
const onSelectorFocus: SelectorProps['onFocus'] = (event) => {
- lastOperation('input');
+ triggerSingleValueChange(0, 'field-switch');
triggerOpen(true, {
inherit: true,
});
- // setActiveIndex(index);
-
- onSharedFocus(event);
+ onFieldFocus(0, 'input', event);
};
const onSelectorBlur: SelectorProps['onBlur'] = (event) => {
- triggerOpen(false);
-
- onSharedBlur(event);
+ onFieldBlur(0, 'input', event);
};
const onSelectorKeyDown: SelectorProps['onKeyDown'] = (event, preventDefault) => {
if (event.key === 'Tab') {
- triggerConfirm();
+ triggerConfirm('keyboard-submit');
+ } else if (event.key === 'Escape') {
+ triggerSingleValueChange(0, 'esc');
+ triggerOpen(false);
}
onKeyDown?.(event, preventDefault);
@@ -630,21 +662,23 @@ function Picker(
}
}, [mergedOpen, activeIndex, picker]);
+ // TODO: Keep the legacy last-operation submit flow for reference during refactor.
+ // TODO: 重构期间暂时保留基于 lastOperation 的旧提交逻辑作为参考。
// >>> For complex picker, we need check if need to focus next one
- useLayoutEffect(() => {
- const lastOp = lastOperation();
-
- // Trade as confirm on field leave
- if (!mergedOpen && lastOp === 'input') {
- triggerOpen(false);
- triggerConfirm();
- }
-
- // Submit with complex picker
- if (!mergedOpen && complexPicker && !needConfirm && lastOp === 'panel') {
- triggerConfirm();
- }
- }, [mergedOpen]);
+ // useLayoutEffect(() => {
+ // const lastOp = lastOperation();
+ //
+ // // Trade as confirm on field leave
+ // if (!mergedOpen && lastOp === 'input') {
+ // triggerOpen(false);
+ // triggerConfirm();
+ // }
+ //
+ // // Submit with complex picker
+ // if (!mergedOpen && complexPicker && !needConfirm && lastOp === 'panel') {
+ // triggerConfirm();
+ // }
+ // }, [mergedOpen]);
// ======================== Render ========================
return (
@@ -677,7 +711,7 @@ function Picker(
onFocus={onSelectorFocus}
onBlur={onSelectorBlur}
onKeyDown={onSelectorKeyDown}
- onSubmit={triggerConfirm}
+ onSubmit={() => triggerConfirm('keyboard-submit')}
// Change
value={selectorValues}
maskFormat={maskFormat}
diff --git a/src/PickerInput/hooks/useFocusEvents.ts b/src/PickerInput/hooks/useFocusEvents.ts
new file mode 100644
index 000000000..d80115f8e
--- /dev/null
+++ b/src/PickerInput/hooks/useFocusEvents.ts
@@ -0,0 +1,79 @@
+import { useEvent } from '@rc-component/util';
+import * as React from 'react';
+
+// ============================= Types =============================
+/** Focus event source. / 焦点事件来源。 */
+export type FocusSource = 'input' | 'panel';
+
+/** React focus event used by Picker elements. / Picker 元素使用的 React 焦点事件。 */
+export type PickerFocusEvent = React.FocusEvent;
+
+/** Focus event handler. / 聚焦事件处理函数。 */
+export type FieldFocusHandler = (
+ index: number,
+ source: FocusSource,
+ event: PickerFocusEvent,
+) => void;
+
+/** Blur event handler. / 失焦事件处理函数。 */
+export type FieldBlurHandler = (
+ index: number,
+ source: FocusSource,
+ event: PickerFocusEvent,
+) => void;
+
+/** Check whether an element belongs to the current focus scope. / 检查元素是否属于当前焦点范围。 */
+export type IsInternalElement = (element: EventTarget | null) => boolean;
+
+/** Notify a Picker focus or blur event. / 通知 Picker 的聚焦或失焦事件。 */
+export type FocusEventHandler = (index: number, event: PickerFocusEvent) => void;
+
+export type UseFocusEventsReturn = [
+ focused: boolean,
+ onFieldFocus: FieldFocusHandler,
+ onFieldBlur: FieldBlurHandler,
+];
+
+// ============================= Utils =============================
+/** Check whether the target belongs to any container. / 判断目标是否属于任意一个容器。 */
+export function isTargetInContainers(
+ target: EventTarget | null,
+ containers: readonly (Element | null)[],
+) {
+ return containers.some(
+ (container) => !!container && (container === target || container.contains(target as Node)),
+ );
+}
+
+/**
+ * Handle field focus and blur events.
+ * 处理 field 的聚焦与失焦事件。
+ *
+ * Ignore blur when `relatedTarget` still belongs to the Picker.
+ * 当 `relatedTarget` 仍属于 Picker 时,忽略本次 blur。
+ */
+export default function useFocusEvents(
+ isInternalElement: IsInternalElement,
+ onFocus?: FocusEventHandler,
+ onBlur?: FocusEventHandler,
+): UseFocusEventsReturn {
+ // Keep the actual focused field so every field focus causes a render. This
+ // gives `useFocusLock` a commit in which it can correct an invalid switch.
+ // 记录实际获得焦点的 field,使每次 field focus 都会触发渲染;
+ // `useFocusLock` 因此可以在 commit 后纠正不允许的切换。
+ const [focusedIndex, setFocusedIndex] = React.useState(null);
+
+ const onFieldFocus = useEvent((index: number, _source: FocusSource, event: PickerFocusEvent) => {
+ setFocusedIndex(index);
+ onFocus?.(index, event);
+ });
+
+ const onFieldBlur = useEvent((index: number, _source: FocusSource, event: PickerFocusEvent) => {
+ if (!isInternalElement(event.relatedTarget)) {
+ setFocusedIndex(null);
+ onBlur?.(index, event);
+ }
+ });
+
+ return [focusedIndex !== null, onFieldFocus, onFieldBlur];
+}
diff --git a/src/PickerInput/hooks/useFocusLock.ts b/src/PickerInput/hooks/useFocusLock.ts
new file mode 100644
index 000000000..a6fa1fc3a
--- /dev/null
+++ b/src/PickerInput/hooks/useFocusLock.ts
@@ -0,0 +1,56 @@
+import { useEvent, useLayoutEffect } from '@rc-component/util';
+import * as React from 'react';
+import { isTargetInContainers } from './useFocusEvents';
+
+interface FocusLockSelectorRef {
+ startInput: HTMLElement;
+ endInput: HTMLElement;
+ focus: (index?: number) => void;
+}
+
+/**
+ * Keep focus on the specified input field while focus moves inside the Picker.
+ * 当焦点在 Picker 内移动时,将其锁定在指定的输入框上。
+ */
+export default function useFocusLock(
+ index: number | null,
+ selectorRef: React.RefObject,
+ popupRef: React.RefObject,
+ triggerOpen: (open: boolean) => void,
+) {
+ const openPicker = useEvent(() => {
+ triggerOpen(true);
+ });
+
+ // Open the Picker and focus the controlled field after it changes.
+ // 当受控 field 发生切换后,重新打开 Picker 并聚焦对应的 field。
+ React.useEffect(() => {
+ if (index !== null) {
+ openPicker();
+ selectorRef.current?.focus(index);
+ }
+ }, [index, openPicker]);
+
+ // DOM focus may change while `index` stays the same, so check after every commit.
+ // DOM 焦点变化时 `index` 可能保持不变,因此每次 commit 后都需要检查。
+ useLayoutEffect(() => {
+ if (index === null) {
+ return;
+ }
+
+ const activeElement = document.activeElement;
+ const inputFields = [selectorRef.current?.startInput, selectorRef.current?.endInput];
+
+ if (isTargetInContainers(activeElement, [popupRef.current])) {
+ return;
+ }
+
+ const focusInOtherField = inputFields.some(
+ (field, fieldIndex) => fieldIndex !== index && isTargetInContainers(activeElement, [field]),
+ );
+
+ if (focusInOtherField) {
+ inputFields[index]?.focus();
+ }
+ });
+}
diff --git a/src/PickerInput/hooks/usePickerRef.ts b/src/PickerInput/hooks/usePickerRef.ts
index 513cedaaf..347ec8e8e 100644
--- a/src/PickerInput/hooks/usePickerRef.ts
+++ b/src/PickerInput/hooks/usePickerRef.ts
@@ -5,8 +5,11 @@ type PickerRefType = Omit & {
focus: (options?: OptionType) => void;
};
-export default function usePickerRef(ref: React.Ref>) {
- const selectorRef = React.useRef>(null);
+export default function usePickerRef<
+ OptionType,
+ SelectorRefType extends PickerRefType = PickerRefType,
+>(ref: React.Ref>) {
+ const selectorRef = React.useRef(null);
React.useImperativeHandle(ref, () => ({
nativeElement: selectorRef.current?.nativeElement,
diff --git a/src/PickerInput/hooks/useRangeDisabledDate.ts b/src/PickerInput/hooks/useRangeDisabledDate.ts
index 3aca5c1d8..f897010bc 100644
--- a/src/PickerInput/hooks/useRangeDisabledDate.ts
+++ b/src/PickerInput/hooks/useRangeDisabledDate.ts
@@ -11,19 +11,18 @@ import { getFromDate } from '../../utils/miscUtil';
export default function useRangeDisabledDate(
values: RangeValueType,
disabled: [boolean, boolean],
- activeIndexList: number[],
+ activeIndex: number,
+ triggeredFields: number[],
generateConfig: GenerateConfig,
locale: Locale,
disabledDate?: DisabledDate,
) {
- const activeIndex = activeIndexList[activeIndexList.length - 1];
-
const rangeDisabledDate: DisabledDate = (date, info) => {
const [start, end] = values;
const mergedInfo = {
...info,
- from: getFromDate(values, activeIndexList),
+ from: getFromDate(values, triggeredFields, activeIndex),
};
// ============================ Disabled ============================
diff --git a/src/PickerInput/hooks/useRangePickerValue.ts b/src/PickerInput/hooks/useRangePickerValue.ts
index ccf3992b2..6b3586cba 100644
--- a/src/PickerInput/hooks/useRangePickerValue.ts
+++ b/src/PickerInput/hooks/useRangePickerValue.ts
@@ -39,6 +39,7 @@ export default function useRangePickerValue {
@@ -64,7 +68,8 @@ export default function useRangePickerValue {
- if (multiplePanel) {
- // Basic offset
- const SAME_CHECKER: Partial> = {
- date: 'month',
- week: 'month',
- month: 'year',
- quarter: 'year',
- };
-
- const mode = SAME_CHECKER[pickerMode];
- if (mode && !isSame(generateConfig, locale, startDate, endDate, mode)) {
- return offsetPanelDate(generateConfig, pickerMode, endDate, -1);
- }
+ // Check whether two dates belong to the same panel.
+ // 判断两个日期是否属于同一个面板。
+ const isSamePanel = (date1: DateType, date2: DateType) => {
+ if (pickerMode === 'year') {
+ return (
+ Math.floor(generateConfig.getYear(date1) / 10) ===
+ Math.floor(generateConfig.getYear(date2) / 10)
+ );
+ }
- // Year offset
- if (pickerMode === 'year' && startDate) {
- const srcYear = Math.floor(generateConfig.getYear(startDate) / 10);
- const tgtYear = Math.floor(generateConfig.getYear(endDate) / 10);
- if (srcYear !== tgtYear) {
- return offsetPanelDate(generateConfig, pickerMode, endDate, -1);
- }
- }
+ const panelMode: PanelMode =
+ pickerMode === 'month' || pickerMode === 'quarter' ? 'year' : 'month';
+ return isSame(generateConfig, locale, date1, date2, panelMode);
+ };
+
+ // Keep both values in the two visible panels when possible. Otherwise put
+ // the end value in the second panel.
+ // 尽量在双面板内同时展示两个值;无法容纳时,将 end 值放在右侧面板。
+ const getEndDatePickerValue = (startDate: DateType, endDate: DateType) => {
+ if (!multiplePanel || !startDate) {
+ return endDate;
}
- return endDate;
+ const nextPanelDate = offsetPanelDate(generateConfig, pickerMode, startDate, 1);
+ const endInPanels = isSamePanel(startDate, endDate) || isSamePanel(nextPanelDate, endDate);
+
+ return endInPanels ? startDate : offsetPanelDate(generateConfig, pickerMode, endDate, -1);
};
// >>> When switch field, reset the picker value as prev field picker value
@@ -164,30 +164,30 @@ export default function useRangePickerValue>> Reset prevActiveIndex when panel closed
+ // >>> Track previous field only during one continuous Picker focus session
React.useEffect(() => {
- if (open) {
+ if (open && preserveOnFieldChange) {
prevActiveIndexRef.current = mergedActiveIndex;
} else {
prevActiveIndexRef.current = null;
}
- }, [open, mergedActiveIndex]);
+ }, [open, preserveOnFieldChange, mergedActiveIndex]);
// >>> defaultPickerValue: Resync to `defaultPickerValue` for each panel focused
useLayoutEffect(() => {
diff --git a/src/PickerInput/hooks/useRangeValue.ts b/src/PickerInput/hooks/useRangeValue.ts
index 6cd0ac367..9039df878 100644
--- a/src/PickerInput/hooks/useRangeValue.ts
+++ b/src/PickerInput/hooks/useRangeValue.ts
@@ -7,7 +7,7 @@ import { formatValue, isSame, isSameTimestamp } from '../../utils/dateUtil';
import { fillIndex } from '../../utils/miscUtil';
import type { RangePickerProps } from '../RangePicker';
import type { ReplacedPickerProps } from '../SinglePicker';
-import useLockEffect from './useLockEffect';
+// import useLockEffect from './useLockEffect';
const EMPTY_VALUE: any[] = [];
@@ -176,11 +176,14 @@ export default function useRangeValue boolean,
+ // useEffectSubmit?: boolean,
): [
/** Trigger `onChange` by check `disabledDate` */
flushSubmit: (index: number, needTriggerChange: boolean) => void,
/** Trigger `onChange` directly without check `disabledDate` */
triggerSubmitChange: (value: ValueType) => boolean,
+ /** Reset calendar and submit values back to the committed value */
+ resetValue: (index?: number) => void,
] {
const {
// MISC
@@ -196,6 +199,8 @@ export default function useRangeValue d) ? false : order;
// ============================= Util =============================
@@ -305,28 +310,35 @@ export default function useRangeValue {
+ if (index === undefined) {
+ triggerCalendarChange(mergedValue);
+ syncWithValue();
+ return;
+ }
+
+ triggerCalendarChange(fillIndex(getCalendarValue(), index, mergedValue[index]));
+ setSubmitValue(fillIndex(submitValue(), index, mergedValue[index]));
+ });
+
// ============================ Effect ============================
- // All finished action trigger after 2 frames
- const interactiveFinished = !focused && !open;
-
- useLockEffect(
- !interactiveFinished,
- () => {
- if (interactiveFinished) {
- // Always try to trigger submit first
- triggerSubmit();
-
- // Trigger calendar change since this is a effect reset
- // https://github.com/ant-design/ant-design/issues/22351
- triggerCalendarChange(mergedValue);
-
- // Sync with value anyway
- syncWithValue();
- }
- },
- 2,
- );
+ // TODO: Keep the legacy effect submit flow for reference during the event-driven refactor.
+ // TODO: 事件驱动提交重构期间,暂时保留旧 effect 提交流程作为参考。
+ // const interactiveFinished = !focused && !open;
+ //
+ // useLockEffect(
+ // enableEffectSubmit && !interactiveFinished,
+ // () => {
+ // if (enableEffectSubmit && interactiveFinished) {
+ // // Always try to trigger submit first
+ // triggerSubmit();
+ //
+ // resetValue();
+ // }
+ // },
+ // 2,
+ // );
// ============================ Return ============================
- return [flushSubmit, triggerSubmit];
+ return [flushSubmit, triggerSubmit, resetValue];
}
diff --git a/src/PickerInput/hooks/useRangeValueChange.ts b/src/PickerInput/hooks/useRangeValueChange.ts
new file mode 100644
index 000000000..14474200e
--- /dev/null
+++ b/src/PickerInput/hooks/useRangeValueChange.ts
@@ -0,0 +1,429 @@
+import { useEvent, useSyncState } from '@rc-component/util';
+import * as React from 'react';
+
+// ============================= Types =============================
+
+/** Change source of a field. / Field 的变更来源。 */
+export type RangeValueChangeSource =
+ | 'input'
+ | 'keyboard-submit'
+ | 'esc'
+ | 'panel-intermediate'
+ | 'panel-final'
+ | 'blur'
+ | 'field-switch'
+ | 'confirm';
+
+/** Resolved operation for one field interaction. / 一次 field 交互最终执行的操作。 */
+export type RangeValueChangeAction =
+ | 'modify'
+ | 'switchNext'
+ | 'switchPrevious'
+ | 'finish'
+ | 'abort'
+ | 'resetCurrent'
+ | 'resetCurrentAndSwitchNext'
+ | 'resetAll';
+
+/** Receive a field interaction and its optional value. / 接收 field 交互及可选变更值。 */
+export type TriggerChange = (
+ index: number,
+ source: RangeValueChangeSource,
+ value?: FieldValue,
+) => void;
+
+/** Read the latest temporary CalendarValue. / 读取最新的临时 CalendarValue。 */
+export type GetCalendarValue = () => readonly (FieldValue | null | undefined)[];
+
+/** Update one field in CalendarValue. / 更新 CalendarValue 中的一个 field。 */
+export type TriggerCalendarChange = (index: number, value: FieldValue) => void;
+
+/**
+ * Flush one field and optionally emit the final change.
+ * 提交一个 field,并按需触发最终 change。
+ */
+export type FlushSubmit = (index: number, needTriggerChange: boolean) => void;
+
+/**
+ * Reset one field, or all fields when index is omitted.
+ * 重置指定 field;未传 index 时重置全部 field。
+ */
+export type ResetValue = (index?: number) => void;
+
+export type UseRangeValueChangeReturn = [
+ currentIndex: number | null,
+ activeIndex: number,
+ triggeredFields: number[],
+ triggerChange: TriggerChange,
+];
+
+interface TriggeredField {
+ index: number;
+ modified: boolean;
+}
+
+// ============================== Hook ==============================
+
+/**
+ * Coordinate CalendarValue updates, part submits and final submits for any
+ * number of fields.
+ * 统一管理任意数量 field 的 CalendarValue 更新、局部提交与最终提交。
+ *
+ * Flow / 流程:
+ * First resolve `source`, `needConfirm`, `allowEmpty` and the field indexes to
+ * one action, then execute that action in a single switch statement.
+ * 先根据 `source`、`needConfirm`、`allowEmpty` 与 field index 得到唯一 action,
+ * 再通过统一的 switch 执行 action。
+ *
+ * - `modify`: update or record the current CalendarValue.
+ * 更新或记录当前 CalendarValue。
+ * - `switchNext`: submit the current field and advance to the next field.
+ * 提交当前 field 并推进到下一个 field。
+ * - `switchPrevious`: settle the current field and return to the previously
+ * handled field without a final submit. / 处理当前 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.
+ * 仅撤销当前 field。
+ * - `resetCurrentAndSwitchNext`: discard the current temporary value, mark the
+ * field as handled, then advance. / 撤销当前临时值,将 field 标记为已处理后推进。
+ * - `resetAll`: discard all temporary values and end the interaction.
+ * 撤销全部临时值并结束本轮交互。
+ */
+export default function useRangeValueChange(
+ fieldCount: number,
+ needConfirm: boolean,
+ allowEmpty: readonly boolean[],
+ getCalendarValue: GetCalendarValue,
+ triggerCalendarChange: TriggerCalendarChange,
+ flushSubmit: FlushSubmit,
+ resetValue: ResetValue,
+): UseRangeValueChangeReturn {
+ // ============================= State =============================
+
+ // Record fields involved in the current interaction and whether each field
+ // has been modified since it became active.
+ // 记录当前一轮交互中触发过的 field,以及它从本次激活后是否发生过修改。
+ const triggeredFieldsRef = React.useRef([]);
+
+ // Track the last explicitly confirmed field. `triggeredFields` also records
+ // focus, so it cannot tell confirmed and unconfirmed values apart.
+ // 记录最后一个明确确认过的 field。`triggeredFields` 同时记录 focus,
+ // 因此无法单独区分已确认值与未确认值。
+ const confirmedIndexRef = React.useRef(null);
+
+ // Keep a render value and a synchronous getter for event handlers.
+ // 同时保存渲染值,以及供事件处理函数同步读取的 getter。
+ const [getCurrentIndex, setCurrentIndex] = useSyncState(null);
+
+ // Keep the latest accepted field for panel and selector rendering.
+ // 保留最后一个被业务接受的 field,供 panel 和 selector 渲染使用;
+ const lastValidIndexRef = React.useRef(undefined);
+
+ // ============================= Record ============================
+
+ // Keep fields unique while preserving their first-triggered order. Omit
+ // `modified` to keep the existing state, or pass it to start a new field
+ // visit and record a modification.
+ // field 保持唯一并保留首次触发顺序。省略 `modified` 时保留原状态;传入时
+ // 用于开始一次新的 field 访问,或记录本次修改。
+ const recordTriggeredField = (index: number, modified?: boolean) => {
+ const field = triggeredFieldsRef.current.find((item) => item.index === index);
+
+ if (field) {
+ if (modified !== undefined) {
+ field.modified = modified;
+ }
+ } else {
+ triggeredFieldsRef.current = [
+ ...triggeredFieldsRef.current,
+ { index, modified: modified ?? false },
+ ];
+ }
+ };
+
+ // ============================= Submit ============================
+
+ // Flush the current field, then finish the round or advance to the next one.
+ // 提交当前 field,随后结束本轮或推进到下一个 field。
+ const submitField = (index: number) => {
+ recordTriggeredField(index);
+
+ // Trigger final change after every field has participated once.
+ // 所有 field 都参与过一次后,触发最终 change。
+ const allFieldsTriggered = triggeredFieldsRef.current.length >= fieldCount;
+ flushSubmit(index, allFieldsTriggered);
+
+ if (allFieldsTriggered) {
+ triggeredFieldsRef.current = [];
+ confirmedIndexRef.current = null;
+ setCurrentIndex(null);
+ } else {
+ const nextIndex = (index + 1) % fieldCount;
+ setCurrentIndex(nextIndex);
+ }
+
+ return allFieldsTriggered;
+ };
+
+ // ============================= Resolve ===========================
+
+ // Resolve an interaction to one action without changing any state.
+ // 仅根据当前状态解析 action,不在判断过程中修改任何状态。
+ const resolveAction = (
+ currentIndex: number | null,
+ index: number,
+ source: RangeValueChangeSource,
+ value?: FieldValue,
+ ): RangeValueChangeAction => {
+ if (source === 'esc') {
+ return 'resetAll';
+ }
+
+ if (currentIndex === null) {
+ // A blur after the interaction has completed still needs to discard any
+ // temporary CalendarValue left by a controlled value.
+ // 一轮交互结束后的 blur 仍需清理受控值留下的临时 CalendarValue。
+ return source === 'blur' ? 'resetAll' : 'abort';
+ }
+
+ const currentValue = value === undefined ? getCalendarValue()[currentIndex] : value;
+ const currentEmpty = currentValue === null || currentValue === undefined;
+ const canSwitch = !currentEmpty || allowEmpty[currentIndex];
+
+ if (source === 'field-switch') {
+ if (index === currentIndex) {
+ return 'abort';
+ }
+
+ const previousIndex = (currentIndex - 1 + fieldCount) % fieldCount;
+ const isPreviousField =
+ index === previousIndex &&
+ triggeredFieldsRef.current.some((field) => field.index === index);
+
+ if (isPreviousField) {
+ const currentUnconfirmed = !currentEmpty && confirmedIndexRef.current !== currentIndex;
+
+ // Keep the legacy needConfirm behavior: only an unconfirmed value locks
+ // the current field. Empty, confirmed or allowEmpty fields may go back.
+ // 保持旧版 needConfirm 行为:仅未确认值会锁定当前 field;当前为空、
+ // 已确认或允许空值时,都可以返回上一个 field。
+ if (needConfirm && currentUnconfirmed && !allowEmpty[currentIndex]) {
+ return 'abort';
+ }
+
+ return 'switchPrevious';
+ }
+
+ const nextIndex = (currentIndex + 1) % fieldCount;
+ if (index !== nextIndex) {
+ return 'abort';
+ }
+
+ 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';
+ }
+
+ return canSwitch ? 'switchNext' : 'resetCurrent';
+ }
+
+ if (index !== currentIndex) {
+ return 'abort';
+ }
+
+ if (source === 'blur') {
+ const interactionModified = triggeredFieldsRef.current.some((field) => field.modified);
+
+ if (!interactionModified) {
+ return 'finish';
+ }
+
+ if (needConfirm) {
+ const currentModified =
+ triggeredFieldsRef.current.find((field) => field.index === currentIndex)?.modified ??
+ false;
+ const allFieldsTriggered = triggeredFieldsRef.current.length >= fieldCount;
+
+ // Blur ends the interaction instead of advancing to an unvisited field.
+ // When the current field allows empty, discard its unconfirmed value
+ // before finishing the round.
+ // blur 用于结束交互,不应继续推进到尚未访问的 field。当前 field
+ // 允许为空时,先丢弃未确认值,再结束本轮。
+ if (!allFieldsTriggered || !canSwitch) {
+ return 'resetAll';
+ }
+
+ if (currentModified) {
+ return allowEmpty[currentIndex] ? 'resetCurrentAndSwitchNext' : 'resetAll';
+ }
+
+ return 'switchNext';
+ }
+
+ if (!canSwitch) {
+ return 'resetAll';
+ }
+
+ return 'switchNext';
+ }
+
+ if (source === 'input' || source === 'panel-intermediate') {
+ return 'modify';
+ }
+
+ if (source === 'panel-final') {
+ return needConfirm ? 'modify' : 'switchNext';
+ }
+
+ if (source === 'keyboard-submit' || source === 'confirm') {
+ return canSwitch ? 'switchNext' : 'abort';
+ }
+
+ return 'abort';
+ };
+
+ // ============================= Trigger ===========================
+
+ // Route every interaction through action resolution, then execute the
+ // resolved action in one place.
+ // 所有交互先统一解析 action,再在一个位置执行对应操作。
+ const triggerChange = useEvent(
+ (index: number, source: RangeValueChangeSource, value?: FieldValue) => {
+ let currentIndex = getCurrentIndex();
+
+ // Start a new interaction from the first non-blur event. A standalone
+ // blur may clean temporary values but must not create an active field.
+ // 第一条非 blur 事件用于建立新一轮交互;单独的 blur 可以清理临时值,
+ // 但不应因此创建 currentIndex。
+ if (currentIndex === null && source !== 'blur' && source !== 'esc') {
+ currentIndex = index;
+ setCurrentIndex(index);
+ recordTriggeredField(index, false);
+ }
+
+ const action = resolveAction(currentIndex, index, source, value);
+ const actionIndex = currentIndex ?? index;
+
+ switch (action) {
+ case 'modify':
+ recordTriggeredField(actionIndex, true);
+ if (confirmedIndexRef.current === actionIndex) {
+ confirmedIndexRef.current = null;
+ }
+ if (value !== undefined) {
+ triggerCalendarChange(actionIndex, value);
+ }
+ break;
+
+ case 'switchNext':
+ if (source === 'panel-final' || value !== undefined) {
+ recordTriggeredField(actionIndex, true);
+ }
+ if (value !== undefined) {
+ triggerCalendarChange(actionIndex, value);
+ }
+ if (needConfirm && (source === 'keyboard-submit' || source === 'confirm')) {
+ confirmedIndexRef.current = actionIndex;
+ }
+ if (submitField(actionIndex) && source === 'field-switch') {
+ // The focus switch finishes the previous round and also starts a
+ // new round from its target field.
+ // 本次 focus 切换既结束上一轮,也以目标 field 开始新一轮。
+ setCurrentIndex(index);
+ }
+ if (source === 'field-switch') {
+ recordTriggeredField(index, false);
+ }
+ break;
+
+ case 'switchPrevious': {
+ if (!needConfirm) {
+ const currentValue = getCalendarValue()[actionIndex];
+ const currentEmpty = currentValue === null || currentValue === undefined;
+
+ if (!currentEmpty || allowEmpty[actionIndex]) {
+ // Going back may part-submit the current field, but must never
+ // finish the whole round before the previous field is edited.
+ // 返回上一个 field 时可以局部提交当前 field,但不能在用户修改
+ // 上一个 field 前结束整轮提交。
+ flushSubmit(actionIndex, false);
+ } else {
+ resetValue(actionIndex);
+ }
+ }
+
+ const previousPosition = triggeredFieldsRef.current.findIndex(
+ (field) => field.index === index,
+ );
+ triggeredFieldsRef.current = triggeredFieldsRef.current.slice(0, previousPosition + 1);
+ recordTriggeredField(index, false);
+ setCurrentIndex(index);
+ break;
+ }
+
+ case 'finish':
+ triggeredFieldsRef.current = [];
+ confirmedIndexRef.current = null;
+ setCurrentIndex(null);
+ 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) {
+ confirmedIndexRef.current = null;
+ }
+ if (submitField(actionIndex) && source === 'field-switch') {
+ // The target field belongs to the next round after the previous
+ // round is completed.
+ // 上一轮结束后,切换目标 field 应归入新一轮。
+ setCurrentIndex(index);
+ }
+ if (source === 'field-switch') {
+ recordTriggeredField(index, false);
+ }
+ break;
+
+ case 'resetAll':
+ resetValue();
+ triggeredFieldsRef.current = [];
+ confirmedIndexRef.current = null;
+ setCurrentIndex(null);
+ break;
+
+ case 'abort':
+ if (source === 'field-switch' && index === actionIndex) {
+ recordTriggeredField(index);
+ }
+ break;
+ }
+ },
+ );
+
+ const currentIndex = getCurrentIndex();
+ lastValidIndexRef.current = currentIndex ?? lastValidIndexRef.current ?? 0;
+
+ const triggeredFields = triggeredFieldsRef.current.map((field) => field.index);
+
+ return [currentIndex, lastValidIndexRef.current, triggeredFields, triggerChange];
+}
diff --git a/src/utils/miscUtil.ts b/src/utils/miscUtil.ts
index e2d5efef9..0930e4259 100644
--- a/src/utils/miscUtil.ts
+++ b/src/utils/miscUtil.ts
@@ -72,12 +72,10 @@ export function getRowFormat(
export function getFromDate(
calendarValues: DateType[],
- activeIndexList: number[],
- activeIndex?: number,
+ triggeredFields: number[],
+ activeIndex: number,
) {
- const mergedActiveIndex =
- activeIndex !== undefined ? activeIndex : activeIndexList[activeIndexList.length - 1];
- const firstValuedIndex = activeIndexList.find((index) => calendarValues[index]);
+ const firstValuedIndex = triggeredFields.find((index) => calendarValues[index]);
- return mergedActiveIndex !== firstValuedIndex ? calendarValues[firstValuedIndex] : undefined;
+ return activeIndex !== firstValuedIndex ? calendarValues[firstValuedIndex] : undefined;
}
diff --git a/tests/disabledTime.spec.tsx b/tests/disabledTime.spec.tsx
index a3872ec08..7149975cf 100644
--- a/tests/disabledTime.spec.tsx
+++ b/tests/disabledTime.spec.tsx
@@ -47,6 +47,7 @@ describe('Picker.DisabledTime', () => {
it('disabledTime on TimeRangePicker', () => {
const { container } = render(
({
disabledHours: () => (type === 'start' ? [1, 3, 5] : [2, 4]),
diff --git a/tests/keyboard.spec.tsx b/tests/keyboard.spec.tsx
index f3fb93352..0e3623c82 100644
--- a/tests/keyboard.spec.tsx
+++ b/tests/keyboard.spec.tsx
@@ -1,7 +1,14 @@
import { act, fireEvent, render } from '@testing-library/react';
import { resetWarned } from '@rc-component/util';
import React from 'react';
-import { DateFnsSinglePicker, DayPicker, getMoment, isOpen, openPicker } from './util/commonUtil';
+import {
+ DateFnsSinglePicker,
+ DayPicker,
+ getMoment,
+ isOpen,
+ openPicker,
+ triggerFocus,
+} from './util/commonUtil';
// TODO: New keyboard interactive
describe('Picker.Keyboard', () => {
@@ -26,7 +33,7 @@ describe('Picker.Keyboard', () => {
const inputEle = container.querySelector('input');
// Focus
- fireEvent.focus(inputEle);
+ triggerFocus(inputEle);
expect(isOpen()).toBeFalsy();
// Key to open
diff --git a/tests/new-range.spec.tsx b/tests/new-range.spec.tsx
index 85a4dd07c..24cfbe0a5 100644
--- a/tests/new-range.spec.tsx
+++ b/tests/new-range.spec.tsx
@@ -16,6 +16,8 @@ import {
isSame,
openPicker,
selectCell,
+ triggerBlur,
+ triggerFocus,
waitFakeTimer,
} from './util/commonUtil';
@@ -45,7 +47,10 @@ describe('NewPicker.Range', () => {
describe('PickerValue', () => {
it('defaultPickerValue should reset every time when opened', () => {
const { container } = render(
- ,
+ ,
);
// Left
@@ -79,6 +84,7 @@ describe('NewPicker.Range', () => {
const { container } = render(
{
const startInput = container.querySelectorAll('input')[0];
const endInput = container.querySelectorAll('input')[1];
- fireEvent.focus(startInput);
+ triggerFocus(startInput);
fireEvent.change(startInput, {
target: {
value: '00:00:00',
@@ -650,7 +656,7 @@ describe('NewPicker.Range', () => {
jest.runAllTimers();
});
- container.querySelector('.focus').focus();
+ triggerFocus(container.querySelector('.focus'));
// Changed by click OK
openPicker(container);
@@ -673,19 +679,21 @@ describe('NewPicker.Range', () => {
,
);
- // Change start time (manually focus since fireEvent.focus not change activeElement)
+ // Change start time
openPicker(container);
const li6 = document.querySelector('.rc-picker-time-panel-column').querySelectorAll('li')[6];
fireEvent.mouseDown(li6);
fireEvent.click(li6);
- document.querySelector('.rc-picker-panel-container').focus();
+ triggerFocus(document.querySelector('.rc-picker-panel-container'));
act(() => {
jest.runAllTimers();
});
// Close panel to auto focus next end field
+ const startFocusedElement = document.activeElement;
fireEvent.mouseDown(document.body);
+ triggerBlur(startFocusedElement as HTMLElement);
act(() => {
jest.runAllTimers();
});
@@ -706,7 +714,9 @@ describe('NewPicker.Range', () => {
});
// Close panel to auto focus next end field
+ const endFocusedElement = document.activeElement;
fireEvent.mouseDown(document.body);
+ triggerBlur(endFocusedElement as HTMLElement);
act(() => {
jest.runAllTimers();
@@ -822,7 +832,7 @@ describe('NewPicker.Range', () => {
const startInput = container.querySelectorAll('input')[0];
const endInput = container.querySelectorAll('input')[1];
- startInput.focus();
+ triggerFocus(startInput);
fireEvent.change(startInput, {
target: {
value: '00:00:00',
@@ -832,7 +842,7 @@ describe('NewPicker.Range', () => {
key: 'Tab',
});
- endInput.focus();
+ triggerFocus(endInput);
fireEvent.change(endInput, {
target: {
value: '02:00:00',
@@ -852,7 +862,7 @@ describe('NewPicker.Range', () => {
const { rerender } = render(renderDemo(true));
await waitFakeTimer();
- fireEvent.focus(document.querySelector('.rc-picker-panel-container'));
+ triggerFocus(document.querySelector('.rc-picker-panel-container'));
selectCell(5);
selectCell(10);
@@ -861,14 +871,14 @@ describe('NewPicker.Range', () => {
// Force close and open again
rerender(renderDemo(false));
- fireEvent.blur(document.querySelector('.rc-picker-panel-container'));
+ triggerBlur(document.querySelector('.rc-picker-panel-container'));
await waitFakeTimer();
rerender(renderDemo(true));
- fireEvent.blur(document.querySelector('.rc-picker-panel-container'));
+ triggerBlur(document.querySelector('.rc-picker-panel-container'));
await waitFakeTimer();
- fireEvent.focus(document.querySelector('.rc-picker-panel-container'));
+ triggerFocus(document.querySelector('.rc-picker-panel-container'));
selectCell(7);
selectCell(11);
@@ -882,7 +892,7 @@ describe('NewPicker.Range', () => {
await waitFakeTimer();
- fireEvent.focus(document.querySelector('.rc-picker-panel-container'));
+ triggerFocus(document.querySelector('.rc-picker-panel-container'));
selectCell(5);
selectCell(10);
@@ -940,7 +950,7 @@ describe('NewPicker.Range', () => {
const startInput = container.querySelectorAll('input')[0];
// Year selection
- fireEvent.focus(startInput);
+ triggerFocus(startInput);
expect(startInput.selectionStart).toEqual(0);
expect(startInput.selectionEnd).toEqual(4);
@@ -969,7 +979,7 @@ describe('NewPicker.Range', () => {
const startInput = container.querySelectorAll('input')[0];
const endInput = container.querySelectorAll('input')[1];
- fireEvent.focus(startInput);
+ triggerFocus(startInput);
fireEvent.paste(startInput, {
clipboardData: {
getData: () => '20200903',
@@ -981,7 +991,7 @@ describe('NewPicker.Range', () => {
// End field
await waitFakeTimer();
- fireEvent.focus(endInput);
+ triggerFocus(endInput);
fireEvent.paste(endInput, {
clipboardData: {
getData: () => '20200905',
@@ -1001,7 +1011,7 @@ describe('NewPicker.Range', () => {
// Simulate focus gained by mousedown, then paste before mouse up.
fireEvent.mouseDown(startInput);
- fireEvent.focus(startInput);
+ triggerFocus(startInput);
const pasteEvent = createEvent.paste(startInput, {
clipboardData: {
@@ -1021,7 +1031,7 @@ describe('NewPicker.Range', () => {
const startInput = container.querySelectorAll('input')[0];
// Year selection
- fireEvent.focus(startInput);
+ triggerFocus(startInput);
fireEvent.mouseDown(startInput);
startInput.selectionStart = 5;
@@ -1037,7 +1047,7 @@ describe('NewPicker.Range', () => {
// Simulate focus gained by mousedown, then key input before mouse up.
fireEvent.mouseDown(startInput);
- fireEvent.focus(startInput);
+ triggerFocus(startInput);
const keyDownEvent = createEvent.keyDown(startInput, {
key: '1',
@@ -1055,7 +1065,7 @@ describe('NewPicker.Range', () => {
const startInput = container.querySelectorAll('input')[0];
fireEvent.mouseDown(startInput);
- fireEvent.focus(startInput);
+ triggerFocus(startInput);
fireEvent.mouseUp(startInput);
expect(startInput.selectionStart).toBeDefined();
@@ -1067,11 +1077,11 @@ describe('NewPicker.Range', () => {
const firstInput = container.querySelectorAll('input')[0];
- fireEvent.focus(firstInput);
+ triggerFocus(firstInput);
expect(firstInput).toHaveValue('YYYYMMDD');
- fireEvent.blur(firstInput);
- await waitFakeTimer();
+ triggerBlur(firstInput);
+ await waitFakeTimer(0, 2);
expect(firstInput).toHaveValue('');
});
@@ -1081,7 +1091,7 @@ describe('NewPicker.Range', () => {
);
const firstInput = container.querySelectorAll('input')[0];
- fireEvent.focus(firstInput);
+ triggerFocus(firstInput);
fireEvent.keyDown(firstInput, {
key: 'Backspace',
@@ -1094,7 +1104,7 @@ describe('NewPicker.Range', () => {
const { container } = render( );
const firstInput = container.querySelectorAll('input')[0];
- fireEvent.focus(firstInput);
+ triggerFocus(firstInput);
fireEvent.keyDown(firstInput, {
key: 'ArrowUp',
@@ -1111,7 +1121,7 @@ describe('NewPicker.Range', () => {
const { container } = render( );
const firstInput = container.querySelectorAll('input')[0];
- fireEvent.focus(firstInput);
+ triggerFocus(firstInput);
const fullText = '20000309';
@@ -1142,7 +1152,7 @@ describe('NewPicker.Range', () => {
openPicker(container);
- fireEvent.focus(document.querySelector('.bamboo'));
+ triggerFocus(document.querySelector('.bamboo'));
expect(onOpenChange).not.toHaveBeenCalledWith(false);
});
@@ -1252,7 +1262,7 @@ describe('NewPicker.Range', () => {
const endInput = container.querySelectorAll('input')[1];
// Start
- fireEvent.focus(startInput);
+ triggerFocus(startInput);
fireEvent.change(startInput, {
target: {
value: '2000-01-07',
@@ -1263,7 +1273,7 @@ describe('NewPicker.Range', () => {
});
// End
- fireEvent.focus(endInput);
+ triggerFocus(endInput);
fireEvent.change(endInput, {
target: {
value: '2000-01-14',
@@ -1284,7 +1294,7 @@ describe('NewPicker.Range', () => {
const endInput = container.querySelectorAll('input')[1];
// Start
- fireEvent.focus(startInput);
+ triggerFocus(startInput);
fireEvent.change(startInput, {
target: {
value: '2000-01-07',
@@ -1295,7 +1305,7 @@ describe('NewPicker.Range', () => {
});
// End
- fireEvent.focus(endInput);
+ triggerFocus(endInput);
fireEvent.change(endInput, {
target: {
value: '2001-01-08',
diff --git a/tests/picker.spec.tsx b/tests/picker.spec.tsx
index 15b964ede..b33fa2fa4 100644
--- a/tests/picker.spec.tsx
+++ b/tests/picker.spec.tsx
@@ -24,6 +24,8 @@ import {
isSame,
openPicker,
selectCell,
+ triggerBlur,
+ triggerFocus,
waitFakeTimer,
} from './util/commonUtil';
@@ -256,7 +258,7 @@ describe('Picker.Basic', () => {
,
);
openPicker(container);
- fireEvent.focus(container.querySelector('input'));
+ triggerFocus(container.querySelector('input'));
// Invalidate value
fireEvent.change(container.querySelector('input'), {
@@ -293,7 +295,7 @@ describe('Picker.Basic', () => {
it('should not throw errow when input end year first', () => {
const { container } = render( );
openPicker(container);
- fireEvent.focus(container.querySelectorAll('input')[1]);
+ triggerFocus(container.querySelectorAll('input')[1]);
expect(() => {
fireEvent.change(container.querySelectorAll('input')[1], {
target: {
@@ -311,11 +313,17 @@ describe('Picker.Basic', () => {
beforeAll(() => {
domMock = spyElementPrototypes(HTMLElement, {
- focus: () => {
+ focus(oriDesc: any, ...rest: any[]) {
focused = true;
+
+ // Call origin
+ oriDesc.value.call(this, ...rest);
},
- blur: () => {
+ blur(oriDesc: any, ...rest: any[]) {
blurred = true;
+
+ // Call origin
+ oriDesc.value.call(this, ...rest);
},
});
});
@@ -337,10 +345,10 @@ describe('Picker.Basic', () => {
,
);
- ref.current!.focus();
+ triggerFocus(ref.current!);
expect(focused).toBeTruthy();
- ref.current!.blur();
+ triggerBlur(ref.current!);
expect(blurred).toBeTruthy();
});
@@ -354,11 +362,11 @@ describe('Picker.Basic', () => {
,
);
- fireEvent.focus(container.querySelector('input'));
+ triggerFocus(container.querySelector('input'));
expect(onFocus).toHaveBeenCalled();
expect(document.querySelector('.rc-picker-focused')).toBeTruthy();
- fireEvent.blur(container.querySelector('input'));
+ triggerBlur(container.querySelector('input'));
expect(onBlur).toHaveBeenCalled();
expect(document.querySelector('.rc-picker-focused')).toBeFalsy();
});
@@ -394,7 +402,7 @@ describe('Picker.Basic', () => {
const $input = container.querySelector('input');
openPicker(container);
- $input.focus();
+ triggerFocus($input);
keyDown(KeyCode.ESC);
expect(document.activeElement).toBe($input);
diff --git a/tests/range.spec.tsx b/tests/range.spec.tsx
index 174a1d64a..72a617b12 100644
--- a/tests/range.spec.tsx
+++ b/tests/range.spec.tsx
@@ -28,6 +28,8 @@ import {
isSame,
openPicker,
selectCell,
+ triggerBlur,
+ triggerFocus,
waitFakeTimer,
} from './util/commonUtil';
@@ -555,10 +557,10 @@ describe('Picker.Range', () => {
,
);
- ref.current!.focus();
+ triggerFocus(ref.current!);
expect(focused).toBeTruthy();
- ref.current!.blur();
+ triggerBlur(ref.current!);
expect(blurred).toBeTruthy();
});
@@ -594,7 +596,7 @@ describe('Picker.Range', () => {
});
it('mode is array', () => {
- const { container } = render( );
+ const { container } = render( );
openPicker(container);
expect(document.querySelector('.rc-picker-year-panel')).toBeTruthy();
@@ -739,7 +741,7 @@ describe('Picker.Range', () => {
selectCell(11);
expect(isOpen()).toBeTruthy();
- fireEvent.blur(container.querySelectorAll('input')[1]);
+ triggerBlur(container.querySelectorAll('input')[1]);
act(() => {
jest.runAllTimers();
@@ -814,8 +816,7 @@ describe('Picker.Range', () => {
act(() => {
fireEvent.mouseDown(container.querySelectorAll('input')[1]);
- fireEvent.blur(container.querySelectorAll('input')[0]);
- fireEvent.focus(container.querySelectorAll('input')[1]);
+ triggerFocus(container.querySelectorAll('input')[1]);
jest.runAllTimers();
});
@@ -949,6 +950,7 @@ describe('Picker.Range', () => {
it('defaultPickerValue', () => {
const { container } = render(
,
@@ -1198,8 +1200,7 @@ describe('Picker.Range', () => {
},
});
- // Force blur since fireEvent blur will not change document.activeElement
- container.querySelectorAll('input')[1].blur();
+ triggerBlur(container.querySelectorAll('input')[1]);
closePicker(container, 1);
expect(document.querySelectorAll('input')[0].value).toEqual('19890903');
@@ -1613,7 +1614,7 @@ describe('Picker.Range', () => {
// back to first panel and clear input value
// `testing-lib` fire the `focus` event but not change the `document.activeElement`
// We call `focus` manually here
- document.querySelectorAll('input')[0].focus();
+ triggerFocus(document.querySelectorAll('input')[0]);
inputValue('', 0);
// reselect date
@@ -1684,7 +1685,7 @@ describe('Picker.Range', () => {
const { container } = render(
,
);
- fireEvent.focus(document.querySelector('input'));
+ triggerFocus(document.querySelector('input'));
function pickerKeyDown(keyCode: number) {
fireEvent.keyDown(container.querySelector('.rc-picker'), {
@@ -1972,7 +1973,7 @@ describe('Picker.Range', () => {
it('selected date when open is true should switch panel', () => {
const { container } = render( );
- fireEvent.focus(container.querySelector('input'));
+ triggerFocus(container.querySelector('input'));
fireEvent.click(document.querySelector('.rc-picker-cell'));
expect(document.querySelectorAll('.rc-picker-input')[1]).toHaveClass('rc-picker-input-active');
@@ -2020,7 +2021,7 @@ describe('Picker.Range', () => {
const onOpenChange = jest.fn();
const { container } = render( );
- fireEvent.focus(container.querySelector('input'));
+ triggerFocus(container.querySelector('input'));
for (let i = 0; i < 2; i++) {
selectCell(24);
@@ -2036,15 +2037,13 @@ describe('Picker.Range', () => {
const { container } = render( );
act(() => {
- fireEvent.focus(container.querySelectorAll('input')[0]);
+ triggerFocus(container.querySelectorAll('input')[0]);
fireEvent.change(container.querySelectorAll('input')[0], {
target: {
value: '2024-06-13',
},
});
- fireEvent.blur(container.querySelectorAll('input')[0]);
-
- fireEvent.focus(container.querySelectorAll('input')[1]);
+ triggerFocus(container.querySelectorAll('input')[1]);
fireEvent.change(container.querySelectorAll('input')[1], {
target: {
value: '2024-06-15',
@@ -2076,7 +2075,9 @@ describe('Picker.Range', () => {
selectCell(2, 0);
// Click outside to blur
+ const focusedElement = document.activeElement;
fireEvent.mouseDown(document.body);
+ triggerBlur(focusedElement as HTMLElement);
fireEvent.mouseUp(document.body);
fireEvent.click(document.body);
@@ -2135,6 +2136,65 @@ describe('Picker.Range', () => {
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();
+ const { container } = render( );
+ const [startInput, endInput] = container.querySelectorAll('input');
+
+ // Select start without confirming, then switch to end and back to start.
+ openPicker(container);
+ expect(document.activeElement).toBe(startInput);
+ expect(container.querySelectorAll('.rc-picker-input')[0]).toHaveClass('rc-picker-input-active');
+ expect(isOpen()).toBeTruthy();
+
+ selectCell(5);
+ openPicker(container, 1);
+
+ expect(document.activeElement).toBe(endInput);
+ expect(container.querySelectorAll('.rc-picker-input')[1]).toHaveClass('rc-picker-input-active');
+ expect(isOpen()).toBeTruthy();
+ expect(startInput).toHaveValue('');
+
+ openPicker(container);
+
+ expect(document.activeElement).toBe(startInput);
+ expect(container.querySelectorAll('.rc-picker-input')[0]).toHaveClass('rc-picker-input-active');
+ expect(isOpen()).toBeTruthy();
+
+ // Blur the whole Picker without clicking OK.
+ fireEvent.mouseDown(document.body);
+ triggerBlur(document.activeElement as HTMLElement);
+ await waitFakeTimer(0, 2);
+
+ expect(onChange).not.toHaveBeenCalled();
+ expect(startInput).toHaveValue('');
+ expect(endInput).toHaveValue('');
+ expect(isOpen()).toBeFalsy();
+ });
+
+ it('should submit confirmed start when allowEmpty end blurs', async () => {
+ const onChange = jest.fn();
+ const { container } = render( );
+ const [, endInput] = container.querySelectorAll('input');
+
+ openPicker(container);
+ selectCell(5);
+ fireEvent.click(document.querySelector('.rc-picker-ok button'));
+
+ expect(document.activeElement).toBe(endInput);
+ expect(container.querySelectorAll('.rc-picker-input')[1]).toHaveClass('rc-picker-input-active');
+
+ fireEvent.mouseDown(document.body);
+ triggerBlur(endInput);
+ await waitFakeTimer(0, 2);
+
+ expect(onChange).toHaveBeenCalledTimes(1);
+ expect(onChange.mock.calls[0][0][0]).toBeTruthy();
+ expect(onChange.mock.calls[0][0][1]).toBeNull();
+ expect(isOpen()).toBeFalsy();
+ });
+
it('should not update preview value in input when previewValue is false', () => {
const { container } = render(
{
- jest.runAllTimers();
- await Promise.resolve();
+export async function waitFakeTimer(advanceTime = 0, times = 1) {
+ for (let i = 0; i < times; i += 1) {
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ if (advanceTime > 0) {
+ jest.advanceTimersByTime(advanceTime);
+ } else {
+ jest.runAllTimers();
+ }
+ });
+ }
+}
+
+interface FocusTarget {
+ focus: () => void;
+}
+
+interface BlurTarget {
+ blur: () => void;
+}
+
+/**
+ * Trigger native focus so jsdom updates `document.activeElement` and blurs the
+ * previous element with this target as `relatedTarget`.
+ */
+export function triggerFocus(target: FocusTarget) {
+ act(() => {
+ target.focus();
+ });
+}
+
+/**
+ * Trigger native blur so jsdom clears its internal focused element. Use this
+ * only for focus leaving without another focus target.
+ */
+export function triggerBlur(target: BlurTarget) {
+ act(() => {
+ target.blur();
});
}
@@ -79,16 +116,16 @@ export function openPicker(container: HTMLElement | ShadowRoot, index = 0) {
const input = container.querySelectorAll('input')[index];
fireEvent.mouseDown(input);
- // Testing lib not trigger real focus
- act(() => {
- input.focus();
- });
+ triggerFocus(input);
fireEvent.click(input);
}
export function closePicker(container: HTMLElement | ShadowRoot, index = 0) {
const input = container.querySelectorAll('input')[index];
- fireEvent.blur(input);
+ const root = input.getRootNode() as Document | ShadowRoot;
+ const activeElement = root.activeElement || document.activeElement;
+
+ triggerBlur(activeElement as HTMLElement);
// Loop to pass all the timer (includes raf)
for (let i = 0; i < 5; i += 1) {